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![Location of button on bridge](/static/images/config_philips_hue.jpg)" + "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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Work mode', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'work_mode', + 'unique_id': 'E1234567890000000009_work_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[n0vyif-entity_ids1][select.x8_pro_omni_work_mode:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'X8 PRO OMNI Work mode', + 'options': list([ + 'mop', + 'mop_after_vacuum', + 'vacuum', + 'vacuum_and_mop', + ]), + }), + 'context': , + 'entity_id': 'select.x8_pro_omni_work_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'vacuum', + }) +# --- # name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_flow_level:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index c216c4c9e4a..a3a891e6a87 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -1288,6 +1288,8 @@ 'options': list([ 'idle', 'emptying_dustbin', + 'washing_mop', + 'drying_mop', ]), }), 'config_entry_id': , @@ -1327,6 +1329,8 @@ 'options': list([ 'idle', 'emptying_dustbin', + 'washing_mop', + 'drying_mop', ]), }), 'context': , diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 3115f1b4040..5965398bd0c 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -107,7 +107,7 @@ async def test_devices_in_dr( [ ("yna5x1", 26), ("5xu9h3", 25), - ("123", 2), + ("123", 3), ], ) async def test_all_entities_loaded( diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index dd7308e18fd..02628554519 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -3,8 +3,14 @@ from dataclasses import dataclass from deebot_client.command import Command -from deebot_client.commands.json import SetCutDirection, SetVolume -from deebot_client.events import CutDirectionEvent, Event, VolumeEvent +from deebot_client.commands.json import ( + SetCleanCount, + SetCutDirection, + SetVolume, + SetWaterInfo, +) +from deebot_client.events import CleanCountEvent, CutDirectionEvent, Event, VolumeEvent +from deebot_client.events.water_info import WaterCustomAmountEvent import pytest from syrupy.assertion import SnapshotAssertion @@ -68,8 +74,34 @@ class NumberTestCase: ), ], ), + ( + "n0vyif", + [ + NumberTestCase( + "number.x8_pro_omni_clean_count", + CleanCountEvent(1), + "1", + 4, + SetCleanCount(4), + ), + NumberTestCase( + "number.x8_pro_omni_volume", + VolumeEvent(5, 11), + "5", + 10, + SetVolume(10), + ), + NumberTestCase( + "number.x8_pro_omni_water_flow_level", + WaterCustomAmountEvent(14), + "14", + 7, + SetWaterInfo(custom_amount=7), + ), + ], + ), ], - ids=["yna5x1", "5xu9h3"], + ids=["yna5x1", "5xu9h3", "n0vyif"], ) async def test_number_entities( hass: HomeAssistant, diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index c3025d99cfa..538ab66bed0 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -4,6 +4,7 @@ from deebot_client.command import Command from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus from deebot_client.events.water_info import WaterAmount, WaterAmountEvent +from deebot_client.events.work_mode import WorkMode, WorkModeEvent import pytest from syrupy.assertion import SnapshotAssertion @@ -34,6 +35,7 @@ def platforms() -> Platform | list[Platform]: async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" event_bus.notify(WaterAmountEvent(WaterAmount.ULTRAHIGH)) + event_bus.notify(WorkModeEvent(WorkMode.VACUUM)) await block_till_done(hass, event_bus) @@ -47,6 +49,12 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): "select.ozmo_950_water_flow_level", ], ), + ( + "n0vyif", + [ + "select.x8_pro_omni_work_mode", + ], + ), ], ) async def test_selects( diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 6c3900ccd19..5e7173912ba 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -46,7 +46,7 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): event_bus.notify(LifeSpanEvent(LifeSpan.FILTER, 56, 40 * 60)) event_bus.notify(LifeSpanEvent(LifeSpan.SIDE_BRUSH, 40, 20 * 60)) event_bus.notify(ErrorEvent(0, "NoError: Robot is operational")) - event_bus.notify(station.StationEvent(station.State.EMPTYING)) + event_bus.notify(station.StationEvent(station.State.EMPTYING_DUSTBIN)) await block_till_done(hass, event_bus) diff --git a/tests/components/ekeybionyx/__init__.py b/tests/components/ekeybionyx/__init__.py new file mode 100644 index 00000000000..334b000c57b --- /dev/null +++ b/tests/components/ekeybionyx/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ekey Bionyx integration.""" diff --git a/tests/components/ekeybionyx/conftest.py b/tests/components/ekeybionyx/conftest.py new file mode 100644 index 00000000000..b6fc9be1572 --- /dev/null +++ b/tests/components/ekeybionyx/conftest.py @@ -0,0 +1,173 @@ +"""Conftest module for ekeybionyx.""" + +from http import HTTPStatus +from unittest.mock import patch + +import pytest + +from homeassistant.components.ekeybionyx.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +def dummy_systems( + num_systems: int, free_wh: int, used_wh: int, own_system: bool = True +) -> list[dict]: + """Create dummy systems.""" + return [ + { + "systemName": f"System {i + 1}", + "systemId": f"946DA01F-9ABD-4D9D-80C7-02AF85C822A{i + 8}", + "ownSystem": own_system, + "functionWebhookQuotas": {"free": free_wh, "used": used_wh}, + } + for i in range(num_systems) + ] + + +@pytest.fixture(name="system") +def mock_systems( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(2, 5, 0), + ) + + +@pytest.fixture(name="no_own_system") +def mock_no_own_systems( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 1, 0, False), + ) + + +@pytest.fixture(name="no_response") +def mock_no_response( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + +@pytest.fixture(name="no_available_webhooks") +def mock_no_available_webhooks( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 0, 0), + ) + + +@pytest.fixture(name="already_set_up") +def mock_already_set_up( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 0, 1), + ) + + +@pytest.fixture(name="webhooks") +def mock_webhooks( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks", + json=[ + { + "functionWebhookId": "946DA01F-9ABD-4D9D-80C7-02AF85C822B9", + "integrationName": "Home Assistant", + "locationName": "A simple string containing 0 to 128 word, space and punctuation characters.", + "functionName": "A simple string containing 0 to 50 word, space and punctuation characters.", + "expiresAt": "2022-05-16T04:11:28.0000000+00:00", + "modificationState": None, + } + ], + ) + + +@pytest.fixture(name="webhook_deletion") +def mock_webhook_deletion( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.delete( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks/946DA01F-9ABD-4D9D-80C7-02AF85C822B9", + status=HTTPStatus.ACCEPTED, + ) + + +@pytest.fixture(name="add_webhook", autouse=True) +def mock_add_webhook( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.post( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks", + status=HTTPStatus.CREATED, + json={ + "functionWebhookId": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + "integrationName": "Home Assistant", + "locationName": "Home Assistant", + "functionName": "Test", + "expiresAt": "2022-05-16T04:11:28.0000000+00:00", + "modificationState": None, + }, + ) + + +@pytest.fixture(name="webhook_id") +def mock_webhook_id(): + """Mock webhook_id.""" + with patch( + "homeassistant.components.webhook.async_generate_id", return_value="1234567890" + ): + yield + + +@pytest.fixture(name="token_hex") +def mock_token_hex(): + """Mock auth property.""" + with patch( + "secrets.token_hex", + return_value="f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + ): + yield + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="test@test.com", + domain=DOMAIN, + data={ + "webhooks": [ + { + "webhook_id": "a2156edca7fb6671e13845314f6fc68622e5dd7c58f17663a487bd28cac247e7", + "name": "Test1", + "auth": "f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + "ekey_id": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + } + ] + }, + unique_id="946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + version=1, + minor_version=1, + ) diff --git a/tests/components/ekeybionyx/test_config_flow.py b/tests/components/ekeybionyx/test_config_flow.py new file mode 100644 index 00000000000..f50cd099dbc --- /dev/null +++ b/tests/components/ekeybionyx/test_config_flow.py @@ -0,0 +1,360 @@ +"""Test the ekey bionyx config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.ekeybionyx.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + SCOPE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from .conftest import dummy_systems + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture +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.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + webhook_id: None, + system: None, + token_hex: None, +) -> 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}" + f"&scope={SCOPE}" + ) + + 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": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert flow.get("step_id") == "choose_system" + + flow2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], {"system": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8"} + ) + assert flow2.get("step_id") == "webhooks" + + flow3 = await hass.config_entries.flow.async_configure( + flow2["flow_id"], + { + "url": "localhost:8123", + }, + ) + + assert flow3.get("errors") == {"base": "no_webhooks_provided", "url": "invalid_url"} + + flow4 = await hass.config_entries.flow.async_configure( + flow3["flow_id"], + { + "webhook1": "Test ", + "webhook2": " Invalid", + "webhook3": "1Invalid", + "webhook4": "Also@Invalid", + "webhook5": "Invalid-Name", + "url": "localhost:8123", + }, + ) + + assert flow4.get("errors") == { + "url": "invalid_url", + "webhook1": "invalid_name", + "webhook2": "invalid_name", + "webhook3": "invalid_name", + "webhook4": "invalid_name", + "webhook5": "invalid_name", + } + + with patch( + "homeassistant.components.ekeybionyx.async_setup_entry", return_value=True + ) as mock_setup: + flow5 = await hass.config_entries.flow.async_configure( + flow2["flow_id"], + { + "webhook1": "Test", + "url": "http://localhost:8123", + }, + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "webhooks": [ + { + "webhook_id": "1234567890", + "name": "Test", + "auth": "f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + "ekey_id": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + } + ] + } + + assert flow5.get("type") is FlowResultType.CREATE_ENTRY + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_no_own_system( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_own_system: None, +) -> None: + """Check no own System 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}" + f"&scope={SCOPE}" + ) + + 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": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "no_own_systems" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_no_available_webhooks( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_available_webhooks: None, +) -> None: + """Check no own System 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}" + f"&scope={SCOPE}" + ) + + 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": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "no_available_webhooks" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_cleanup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + already_set_up: None, + webhooks: None, + webhook_deletion: None, +) -> None: + """Check no own System 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}" + f"&scope={SCOPE}" + ) + + 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": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert flow.get("step_id") == "delete_webhooks" + + flow2 = await hass.config_entries.flow.async_configure(flow["flow_id"], {}) + assert flow2.get("type") is FlowResultType.SHOW_PROGRESS + + aioclient_mock.clear_requests() + + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 1, 0), + ) + + await hass.async_block_till_done() + + assert ( + hass.config_entries.flow.async_get(flow2["flow_id"]).get("step_id") + == "webhooks" + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_error_on_setup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_response: None, +) -> None: + """Check no own System 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}" + f"&scope={SCOPE}" + ) + + 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": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "cannot_connect" diff --git a/tests/components/ekeybionyx/test_init.py b/tests/components/ekeybionyx/test_init.py new file mode 100644 index 00000000000..992d60c3034 --- /dev/null +++ b/tests/components/ekeybionyx/test_init.py @@ -0,0 +1,30 @@ +"""Module contains tests for the ekeybionyx component's initialization. + +Functions: + test_async_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + Test a successful setup entry and unload of entry. +""" + +from homeassistant.components.ekeybionyx.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload of entry.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index 548f374010e..fab0cdf10c9 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -1,5 +1,7 @@ """Test the Elk-M1 Control config flow.""" +from __future__ import annotations + from dataclasses import asdict from unittest.mock import patch @@ -7,8 +9,15 @@ from elkm1_lib.discovery import ElkSystem import pytest from homeassistant import config_entries -from homeassistant.components.elkm1.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.components.elkm1.const import CONF_AUTO_CONFIGURE, DOMAIN +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_PASSWORD, + CONF_PREFIX, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr @@ -36,6 +45,21 @@ ELK_DISCOVERY_INFO_NON_STANDARD_PORT = asdict(ELK_DISCOVERY_NON_STANDARD_PORT) MODULE = "homeassistant.components.elkm1" +@pytest.fixture +def mock_config_entry(): + """Create a mock config entry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: f"elks://{MOCK_IP_ADDRESS}", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + }, + unique_id=MOCK_MAC, + ) + + async def test_discovery_ignored_entry(hass: HomeAssistant) -> None: """Test we abort on ignored entry.""" config_entry = MockConfigEntry( @@ -86,11 +110,11 @@ async def test_form_user_with_secure_elk_no_discovery(hass: HomeAssistant) -> No result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -98,11 +122,11 @@ async def test_form_user_with_secure_elk_no_discovery(hass: HomeAssistant) -> No assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1" assert result2["data"] == { - "auto_configure": True, - "host": "elks://1.2.3.4", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://1.2.3.4", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -143,11 +167,11 @@ async def test_form_user_with_insecure_elk_skip_discovery(hass: HomeAssistant) - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "non-secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "non-secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -155,11 +179,11 @@ async def test_form_user_with_insecure_elk_skip_discovery(hass: HomeAssistant) - assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1" assert result2["data"] == { - "auto_configure": True, - "host": "elk://1.2.3.4", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://1.2.3.4", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -200,11 +224,11 @@ async def test_form_user_with_insecure_elk_no_discovery(hass: HomeAssistant) -> result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "non-secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "non-secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -212,11 +236,11 @@ async def test_form_user_with_insecure_elk_no_discovery(hass: HomeAssistant) -> assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1" assert result2["data"] == { - "auto_configure": True, - "host": "elk://1.2.3.4", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://1.2.3.4", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -255,11 +279,11 @@ async def test_form_user_with_insecure_elk_times_out(hass: HomeAssistant) -> Non result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "non-secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "non-secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -267,6 +291,44 @@ async def test_form_user_with_insecure_elk_times_out(hass: HomeAssistant) -> Non assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + # Retry with successful connection + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(), + _patch_elk(elk=mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROTOCOL: "non-secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "ElkM1" + assert result3["data"] == { + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://1.2.3.4", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + async def test_form_user_with_secure_elk_no_discovery_ip_already_configured( hass: HomeAssistant, @@ -295,11 +357,11 @@ async def test_form_user_with_secure_elk_no_discovery_ip_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "secure", - "address": "127.0.0.1", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "127.0.0.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -344,8 +406,8 @@ async def test_form_user_with_secure_elk_with_discovery(hass: HomeAssistant) -> result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { - "username": "test-username", - "password": "test-password", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() @@ -353,11 +415,11 @@ async def test_form_user_with_secure_elk_with_discovery(hass: HomeAssistant) -> assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "ElkM1 ddeeff" assert result3["data"] == { - "auto_configure": True, - "host": "elks://127.0.0.1", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://127.0.0.1", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert result3["result"].unique_id == "aa:bb:cc:dd:ee:ff" assert len(mock_setup.mock_calls) == 1 @@ -402,11 +464,11 @@ async def test_form_user_with_secure_elk_with_discovery_pick_manual( result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { - "protocol": "secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -414,11 +476,11 @@ async def test_form_user_with_secure_elk_with_discovery_pick_manual( assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "ElkM1" assert result3["data"] == { - "auto_configure": True, - "host": "elks://1.2.3.4", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://1.2.3.4", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert result3["result"].unique_id is None assert len(mock_setup.mock_calls) == 1 @@ -463,11 +525,11 @@ async def test_form_user_with_secure_elk_with_discovery_pick_manual_direct_disco result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { - "protocol": "secure", - "address": "127.0.0.1", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "127.0.0.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -475,11 +537,11 @@ async def test_form_user_with_secure_elk_with_discovery_pick_manual_direct_disco assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "ElkM1 ddeeff" assert result3["data"] == { - "auto_configure": True, - "host": "elks://127.0.0.1", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://127.0.0.1", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert result3["result"].unique_id == MOCK_MAC assert len(mock_setup.mock_calls) == 1 @@ -515,11 +577,11 @@ async def test_form_user_with_tls_elk_no_discovery(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "TLS 1.2", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "TLS 1.2", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -527,11 +589,11 @@ async def test_form_user_with_tls_elk_no_discovery(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1" assert result2["data"] == { - "auto_configure": True, - "host": "elksv1_2://1.2.3.4", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elksv1_2://1.2.3.4", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -566,9 +628,9 @@ async def test_form_user_with_non_secure_elk_no_discovery(hass: HomeAssistant) - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "non-secure", - "address": "1.2.3.4", - "prefix": "guest_house", + CONF_PROTOCOL: "non-secure", + CONF_ADDRESS: "1.2.3.4", + CONF_PREFIX: "guest_house", }, ) await hass.async_block_till_done() @@ -576,11 +638,11 @@ async def test_form_user_with_non_secure_elk_no_discovery(hass: HomeAssistant) - assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "guest_house" assert result2["data"] == { - "auto_configure": True, - "host": "elk://1.2.3.4", - "prefix": "guest_house", - "username": "", - "password": "", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://1.2.3.4", + CONF_PREFIX: "guest_house", + CONF_USERNAME: "", + CONF_PASSWORD: "", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -615,9 +677,9 @@ async def test_form_user_with_serial_elk_no_discovery(hass: HomeAssistant) -> No result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "serial", - "address": "/dev/ttyS0:115200", - "prefix": "", + CONF_PROTOCOL: "serial", + CONF_ADDRESS: "/dev/ttyS0:115200", + CONF_PREFIX: "", }, ) await hass.async_block_till_done() @@ -625,11 +687,11 @@ async def test_form_user_with_serial_elk_no_discovery(hass: HomeAssistant) -> No assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1" assert result2["data"] == { - "auto_configure": True, - "host": "serial:///dev/ttyS0:115200", - "prefix": "", - "username": "", - "password": "", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "serial:///dev/ttyS0:115200", + CONF_PREFIX: "", + CONF_USERNAME: "", + CONF_PASSWORD: "", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -659,17 +721,55 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + # Retry with successful connection + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "ElkM1" + assert result3["data"] == { + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://1.2.3.4", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + async def test_unknown_exception(hass: HomeAssistant) -> None: """Test we handle an unknown exception during connecting.""" @@ -695,17 +795,56 @@ async def test_unknown_exception(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) assert result2["type"] is FlowResultType.FORM + # Simulate an unexpected exception (ValueError) and verify the flow returns an "unknown" error assert result2["errors"] == {"base": "unknown"} + # Retry with successful connection + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "ElkM1" + assert result3["data"] == { + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://1.2.3.4", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth error.""" @@ -722,17 +861,55 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "test-password", - "prefix": "", + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", }, ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} + # Retry with valid auth + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "correct-password", + CONF_PREFIX: "", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "ElkM1" + assert result3["data"] == { + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://1.2.3.4", + CONF_PASSWORD: "correct-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + async def test_form_invalid_auth_no_password(hass: HomeAssistant) -> None: """Test we handle invalid auth error when no password is provided.""" @@ -749,17 +926,55 @@ async def test_form_invalid_auth_no_password(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "secure", - "address": "1.2.3.4", - "username": "test-username", - "password": "", - "prefix": "", + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "", + CONF_PREFIX: "", }, ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} + # Retry with valid password + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "correct-password", + CONF_PREFIX: "", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "ElkM1" + assert result3["data"] == { + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://1.2.3.4", + CONF_PASSWORD: "correct-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + async def test_form_import(hass: HomeAssistant) -> None: """Test we get the form with import source.""" @@ -780,11 +995,11 @@ async def test_form_import(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ - "host": "elks://1.2.3.4", - "username": "friend", - "password": "love", + CONF_HOST: "elks://1.2.3.4", + CONF_USERNAME: "friend", + CONF_PASSWORD: "love", "temperature_unit": "C", - "auto_configure": False, + CONF_AUTO_CONFIGURE: False, "keypad": { "enabled": True, "exclude": [], @@ -793,7 +1008,7 @@ async def test_form_import(hass: HomeAssistant) -> None: "output": {"enabled": False, "exclude": [], "include": []}, "counter": {"enabled": False, "exclude": [], "include": []}, "plc": {"enabled": False, "exclude": [], "include": []}, - "prefix": "ohana", + CONF_PREFIX: "ohana", "setting": {"enabled": False, "exclude": [], "include": []}, "area": {"enabled": False, "exclude": [], "include": []}, "task": {"enabled": False, "exclude": [], "include": []}, @@ -811,20 +1026,20 @@ async def test_form_import(hass: HomeAssistant) -> None: assert result["title"] == "ohana" assert result["data"] == { - "auto_configure": False, - "host": "elks://1.2.3.4", + CONF_AUTO_CONFIGURE: False, + CONF_HOST: "elks://1.2.3.4", "keypad": {"enabled": True, "exclude": [], "include": [[1, 1], [2, 2], [3, 3]]}, "output": {"enabled": False, "exclude": [], "include": []}, - "password": "love", + CONF_PASSWORD: "love", "plc": {"enabled": False, "exclude": [], "include": []}, - "prefix": "ohana", + CONF_PREFIX: "ohana", "setting": {"enabled": False, "exclude": [], "include": []}, "area": {"enabled": False, "exclude": [], "include": []}, "counter": {"enabled": False, "exclude": [], "include": []}, "task": {"enabled": False, "exclude": [], "include": []}, "temperature_unit": "C", "thermostat": {"enabled": False, "exclude": [], "include": []}, - "username": "friend", + CONF_USERNAME: "friend", "zone": {"enabled": True, "exclude": [[15, 15], [28, 208]], "include": []}, } assert len(mock_setup.mock_calls) == 1 @@ -850,11 +1065,11 @@ async def test_form_import_device_discovered(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ - "host": "elks://127.0.0.1", - "username": "friend", - "password": "love", + CONF_HOST: "elks://127.0.0.1", + CONF_USERNAME: "friend", + CONF_PASSWORD: "love", "temperature_unit": "C", - "auto_configure": False, + CONF_AUTO_CONFIGURE: False, "keypad": { "enabled": True, "exclude": [], @@ -863,7 +1078,7 @@ async def test_form_import_device_discovered(hass: HomeAssistant) -> None: "output": {"enabled": False, "exclude": [], "include": []}, "counter": {"enabled": False, "exclude": [], "include": []}, "plc": {"enabled": False, "exclude": [], "include": []}, - "prefix": "ohana", + CONF_PREFIX: "ohana", "setting": {"enabled": False, "exclude": [], "include": []}, "area": {"enabled": False, "exclude": [], "include": []}, "task": {"enabled": False, "exclude": [], "include": []}, @@ -881,20 +1096,20 @@ async def test_form_import_device_discovered(hass: HomeAssistant) -> None: assert result["title"] == "ohana" assert result["result"].unique_id == MOCK_MAC assert result["data"] == { - "auto_configure": False, - "host": "elks://127.0.0.1", + CONF_AUTO_CONFIGURE: False, + CONF_HOST: "elks://127.0.0.1", "keypad": {"enabled": True, "exclude": [], "include": [[1, 1], [2, 2], [3, 3]]}, "output": {"enabled": False, "exclude": [], "include": []}, - "password": "love", + CONF_PASSWORD: "love", "plc": {"enabled": False, "exclude": [], "include": []}, - "prefix": "ohana", + CONF_PREFIX: "ohana", "setting": {"enabled": False, "exclude": [], "include": []}, "area": {"enabled": False, "exclude": [], "include": []}, "counter": {"enabled": False, "exclude": [], "include": []}, "task": {"enabled": False, "exclude": [], "include": []}, "temperature_unit": "C", "thermostat": {"enabled": False, "exclude": [], "include": []}, - "username": "friend", + CONF_USERNAME: "friend", "zone": {"enabled": True, "exclude": [[15, 15], [28, 208]], "include": []}, } assert len(mock_setup.mock_calls) == 1 @@ -920,11 +1135,11 @@ async def test_form_import_non_secure_device_discovered(hass: HomeAssistant) -> DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ - "host": "elk://127.0.0.1:2101", - "username": "", - "password": "", - "auto_configure": True, - "prefix": "ohana", + CONF_HOST: "elk://127.0.0.1:2101", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_AUTO_CONFIGURE: True, + CONF_PREFIX: "ohana", }, ) await hass.async_block_till_done() @@ -933,11 +1148,11 @@ async def test_form_import_non_secure_device_discovered(hass: HomeAssistant) -> assert result["title"] == "ohana" assert result["result"].unique_id == MOCK_MAC assert result["data"] == { - "auto_configure": True, - "host": "elk://127.0.0.1:2101", - "password": "", - "prefix": "ohana", - "username": "", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://127.0.0.1:2101", + CONF_PASSWORD: "", + CONF_PREFIX: "ohana", + CONF_USERNAME: "", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -964,11 +1179,11 @@ async def test_form_import_non_secure_non_stanadard_port_device_discovered( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ - "host": "elk://127.0.0.1:444", - "username": "", - "password": "", - "auto_configure": True, - "prefix": "ohana", + CONF_HOST: "elk://127.0.0.1:444", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_AUTO_CONFIGURE: True, + CONF_PREFIX: "ohana", }, ) await hass.async_block_till_done() @@ -977,11 +1192,11 @@ async def test_form_import_non_secure_non_stanadard_port_device_discovered( assert result["title"] == "ohana" assert result["result"].unique_id == MOCK_MAC assert result["data"] == { - "auto_configure": True, - "host": "elk://127.0.0.1:444", - "password": "", - "prefix": "ohana", - "username": "", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://127.0.0.1:444", + CONF_PASSWORD: "", + CONF_PREFIX: "ohana", + CONF_USERNAME: "", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -998,11 +1213,11 @@ async def test_form_import_non_secure_device_discovered_invalid_auth( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ - "host": "elks://127.0.0.1", - "username": "invalid", - "password": "", - "auto_configure": False, - "prefix": "ohana", + CONF_HOST: "elks://127.0.0.1", + CONF_USERNAME: "invalid", + CONF_PASSWORD: "", + CONF_AUTO_CONFIGURE: False, + CONF_PREFIX: "ohana", }, ) await hass.async_block_till_done() @@ -1024,11 +1239,11 @@ async def test_form_import_existing(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ - "host": f"elks://{MOCK_IP_ADDRESS}", - "username": "friend", - "password": "love", + CONF_HOST: f"elks://{MOCK_IP_ADDRESS}", + CONF_USERNAME: "friend", + CONF_PASSWORD: "love", "temperature_unit": "C", - "auto_configure": False, + CONF_AUTO_CONFIGURE: False, "keypad": { "enabled": True, "exclude": [], @@ -1037,7 +1252,7 @@ async def test_form_import_existing(hass: HomeAssistant) -> None: "output": {"enabled": False, "exclude": [], "include": []}, "counter": {"enabled": False, "exclude": [], "include": []}, "plc": {"enabled": False, "exclude": [], "include": []}, - "prefix": "ohana", + CONF_PREFIX: "ohana", "setting": {"enabled": False, "exclude": [], "include": []}, "area": {"enabled": False, "exclude": [], "include": []}, "task": {"enabled": False, "exclude": [], "include": []}, @@ -1183,8 +1398,8 @@ async def test_discovered_by_discovery(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "username": "test-username", - "password": "test-password", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() @@ -1192,11 +1407,11 @@ async def test_discovered_by_discovery(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1 ddeeff" assert result2["data"] == { - "auto_configure": True, - "host": "elks://127.0.0.1", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://127.0.0.1", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -1233,8 +1448,8 @@ async def test_discovered_by_discovery_non_standard_port(hass: HomeAssistant) -> result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "username": "test-username", - "password": "test-password", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() @@ -1242,11 +1457,11 @@ async def test_discovered_by_discovery_non_standard_port(hass: HomeAssistant) -> assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1 ddeeff" assert result2["data"] == { - "auto_configure": True, - "host": "elks://127.0.0.1:444", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://127.0.0.1:444", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -1304,8 +1519,8 @@ async def test_discovered_by_dhcp_udp_responds(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "username": "test-username", - "password": "test-password", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() @@ -1313,11 +1528,11 @@ async def test_discovered_by_dhcp_udp_responds(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1 ddeeff" assert result2["data"] == { - "auto_configure": True, - "host": "elks://127.0.0.1", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://127.0.0.1", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -1354,7 +1569,7 @@ async def test_discovered_by_dhcp_udp_responds_with_nonsecure_port( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "non-secure", + CONF_PROTOCOL: "non-secure", }, ) await hass.async_block_till_done() @@ -1362,11 +1577,11 @@ async def test_discovered_by_dhcp_udp_responds_with_nonsecure_port( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1 ddeeff" assert result2["data"] == { - "auto_configure": True, - "host": "elk://127.0.0.1", - "password": "", - "prefix": "", - "username": "", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://127.0.0.1", + CONF_PASSWORD: "", + CONF_PREFIX: "", + CONF_USERNAME: "", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -1408,18 +1623,18 @@ async def test_discovered_by_dhcp_udp_responds_existing_config_entry( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": "test-username", "password": "test-password"}, + {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "ElkM1 ddeeff" assert result2["data"] == { - "auto_configure": True, - "host": "elks://127.0.0.1", - "password": "test-password", - "prefix": "ddeeff", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://127.0.0.1", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "ddeeff", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 2 @@ -1477,8 +1692,8 @@ async def test_multiple_instances_with_discovery(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { - "username": "test-username", - "password": "test-password", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() @@ -1486,11 +1701,11 @@ async def test_multiple_instances_with_discovery(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "ElkM1 ddeeff" assert result3["data"] == { - "auto_configure": True, - "host": "elks://127.0.0.1", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://127.0.0.1", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -1526,8 +1741,8 @@ async def test_multiple_instances_with_discovery(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { - "username": "test-username", - "password": "test-password", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() @@ -1535,11 +1750,11 @@ async def test_multiple_instances_with_discovery(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "ElkM1 ddeefe" assert result3["data"] == { - "auto_configure": True, - "host": "elks://127.0.0.2", - "password": "test-password", - "prefix": "ddeefe", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elks://127.0.0.2", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "ddeefe", + CONF_USERNAME: "test-username", } assert len(mock_setup_entry.mock_calls) == 1 @@ -1568,9 +1783,9 @@ async def test_multiple_instances_with_discovery(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "non-secure", - "address": "1.2.3.4", - "prefix": "guest_house", + CONF_PROTOCOL: "non-secure", + CONF_ADDRESS: "1.2.3.4", + CONF_PREFIX: "guest_house", }, ) await hass.async_block_till_done() @@ -1578,11 +1793,11 @@ async def test_multiple_instances_with_discovery(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "guest_house" assert result2["data"] == { - "auto_configure": True, - "host": "elk://1.2.3.4", - "prefix": "guest_house", - "username": "", - "password": "", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://1.2.3.4", + CONF_PREFIX: "guest_house", + CONF_USERNAME: "", + CONF_PASSWORD: "", } assert len(mock_setup_entry.mock_calls) == 1 @@ -1629,9 +1844,9 @@ async def test_multiple_instances_with_tls_v12(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { - "protocol": "TLS 1.2", - "username": "test-username", - "password": "test-password", + CONF_PROTOCOL: "TLS 1.2", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() @@ -1639,11 +1854,11 @@ async def test_multiple_instances_with_tls_v12(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "ElkM1 ddeeff" assert result3["data"] == { - "auto_configure": True, - "host": "elksv1_2://127.0.0.1", - "password": "test-password", - "prefix": "", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elksv1_2://127.0.0.1", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "", + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -1679,9 +1894,9 @@ async def test_multiple_instances_with_tls_v12(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { - "protocol": "TLS 1.2", - "username": "test-username", - "password": "test-password", + CONF_PROTOCOL: "TLS 1.2", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() @@ -1689,11 +1904,11 @@ async def test_multiple_instances_with_tls_v12(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "ElkM1 ddeefe" assert result3["data"] == { - "auto_configure": True, - "host": "elksv1_2://127.0.0.2", - "password": "test-password", - "prefix": "ddeefe", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elksv1_2://127.0.0.2", + CONF_PASSWORD: "test-password", + CONF_PREFIX: "ddeefe", + CONF_USERNAME: "test-username", } assert len(mock_setup_entry.mock_calls) == 1 @@ -1722,11 +1937,11 @@ async def test_multiple_instances_with_tls_v12(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "protocol": "TLS 1.2", - "address": "1.2.3.4", - "prefix": "guest_house", - "password": "test-password", - "username": "test-username", + CONF_PROTOCOL: "TLS 1.2", + CONF_ADDRESS: "1.2.3.4", + CONF_PREFIX: "guest_house", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", }, ) await hass.async_block_till_done() @@ -1734,10 +1949,392 @@ async def test_multiple_instances_with_tls_v12(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "guest_house" assert result2["data"] == { - "auto_configure": True, - "host": "elksv1_2://1.2.3.4", - "prefix": "guest_house", - "password": "test-password", - "username": "test-username", + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elksv1_2://1.2.3.4", + CONF_PREFIX: "guest_house", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_nonsecure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow switching to non-secure protocol.""" + # Add mock_config_entry to hass before updating + mock_config_entry.add_to_hass(hass) + + # Update mock_config_entry.data using async_update_entry + hass.config_entries.async_update_entry( + mock_config_entry, + data={ + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://localhost", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_PREFIX: "", + }, + ) + + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # Mock elk library to simulate successful connection + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_elk(mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROTOCOL: "non-secure", + CONF_ADDRESS: "1.2.3.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # Verify the config entry was updated with the new data + assert dict(mock_config_entry.data) == { + CONF_AUTO_CONFIGURE: True, + CONF_HOST: "elk://1.2.3.4", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_PREFIX: "", + } + + # Verify the setup was called during reload + mock_setup_entry.assert_called_once() + + # Verify the elk library was initialized and connected + assert mocked_elk.connect.call_count == 1 + assert mocked_elk.disconnect.call_count == 1 + + +async def test_reconfigure_tls( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow switching to TLS 1.2 protocol, validating host, username, and password update.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(no_device=True), # ensure no UDP/DNS work + _patch_elk(mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: "127.0.0.1", + CONF_PROTOCOL: "TLS 1.2", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "elksv1_2://127.0.0.1" + assert mock_config_entry.data[CONF_USERNAME] == "test-username" + assert mock_config_entry.data[CONF_PASSWORD] == "test-password" + mock_setup_entry.assert_called_once() + + +async def test_reconfigure_device_offline( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow fails when device is offline.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mocked_elk = mock_elk(invalid_auth=None, sync_complete=None) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=mocked_elk), + patch("homeassistant.components.elkm1.config_flow.VALIDATE_TIMEOUT", 0), + patch("homeassistant.components.elkm1.config_flow.LOGIN_TIMEOUT", 0), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Retry with successful connection + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + mock_setup_entry.assert_called_once() + + +async def test_reconfigure_invalid_auth( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow with invalid authentication.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + elk = mock_elk(invalid_auth=True, sync_complete=True) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=elk), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "wronguser", + CONF_PASSWORD: "wrongpass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} + + # Retry with correct auth + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "127.0.0.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + mock_setup_entry.assert_called_once() + + +async def test_reconfigure_different_device( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Abort reconfigure if the device unique_id differs.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, unique_id=MOCK_MAC) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + different_device = ElkSystem("bb:cc:dd:ee:ff:aa", "1.2.3.4", 2601) + elk = mock_elk(invalid_auth=False, sync_complete=True) + + with _patch_discovery(device=different_device), _patch_elk(elk): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test", + CONF_PASSWORD: "test", + }, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Abort occurs when the discovered device's unique_id does not match the existing config entry. + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_unknown_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow with an unexpected exception.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + elk = mock_elk(invalid_auth=None, sync_complete=None, exception=OSError) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=elk), + patch("homeassistant.components.elkm1.config_flow.VALIDATE_TIMEOUT", 0), + patch("homeassistant.components.elkm1.config_flow.LOGIN_TIMEOUT", 0), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "1.2.3.4", + CONF_USERNAME: "test", + CONF_PASSWORD: "test", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + # Retry with successful connection + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + + with ( + _patch_discovery(no_device=True), + _patch_elk(elk=mocked_elk), + patch( + "homeassistant.components.elkm1.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROTOCOL: "secure", + CONF_ADDRESS: "127.0.0.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + mock_setup_entry.assert_called_once() + + +async def test_reconfigure_preserves_existing_config_entry_fields( + hass: HomeAssistant, +) -> None: + """Test reconfigure only updates changed fields and preserves existing config entry data.""" + # Simulate a config entry imported from yaml with extra fields + initial_data = { + CONF_HOST: "elks://1.2.3.4", + CONF_USERNAME: "olduser", + CONF_PASSWORD: "oldpass", + CONF_PREFIX: "oldprefix", + CONF_AUTO_CONFIGURE: False, + "extra_field": "should_be_preserved", + "another_field": 42, + } + config_entry = MockConfigEntry( + domain=DOMAIN, + data=initial_data, + unique_id=MOCK_MAC, + ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await config_entry.start_reconfigure_flow(hass) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mocked_elk = mock_elk(invalid_auth=False, sync_complete=True) + with ( + _patch_discovery(no_device=True), + _patch_elk(mocked_elk), + patch("homeassistant.components.elkm1.async_setup_entry", return_value=True), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "newuser", + CONF_PASSWORD: "newpass", + CONF_ADDRESS: "5.6.7.8", + CONF_PROTOCOL: "secure", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + await hass.async_block_till_done() + updated_entry = hass.config_entries.async_get_entry(config_entry.entry_id) + assert updated_entry is not None + assert updated_entry.data[CONF_HOST] == "elks://5.6.7.8" + assert updated_entry.data[CONF_USERNAME] == "newuser" + assert updated_entry.data[CONF_PASSWORD] == "newpass" + assert updated_entry.data[CONF_AUTO_CONFIGURE] is False + assert updated_entry.data[CONF_PREFIX] == "oldprefix" + assert updated_entry.data["extra_field"] == "should_be_preserved" + assert updated_entry.data["another_field"] == 42 diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 9e7a2151b04..9addf6c1001 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -850,7 +850,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_consumption_1", "beers")}, "translation_placeholders": { "energy_units": ENERGY_UNITS_STRING, - "gas_units": "CCF, ft³, m³, L", + "gas_units": "CCF, ft³, m³, L, MCF", }, }, { @@ -879,7 +879,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_price_2", "EUR/invalid")}, "translation_placeholders": { "price_units": ( - f"{ENERGY_PRICE_UNITS_STRING}, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" + f"{ENERGY_PRICE_UNITS_STRING}, EUR/CCF, EUR/ft³, EUR/m³, EUR/L, EUR/MCF" ) }, }, @@ -1060,7 +1060,9 @@ async def test_validation_water( { "type": "entity_unexpected_unit_water", "affected_entities": {("sensor.water_consumption_1", "beers")}, - "translation_placeholders": {"water_units": "CCF, ft³, m³, gal, L"}, + "translation_placeholders": { + "water_units": "CCF, ft³, m³, gal, L, MCF" + }, }, { "type": "recorder_untracked", @@ -1087,7 +1089,7 @@ async def test_validation_water( "type": "entity_unexpected_unit_water_price", "affected_entities": {("sensor.water_price_2", "EUR/invalid")}, "translation_placeholders": { - "price_units": "EUR/CCF, EUR/ft³, EUR/m³, EUR/gal, EUR/L" + "price_units": "EUR/CCF, EUR/ft³, EUR/m³, EUR/gal, EUR/L, EUR/MCF" }, }, ], diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 9e94dab5a4c..1d70a298441 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -193,6 +193,22 @@ def _load_json_2_meter_data( mocked_data: EnvoyData, json_fixture: dict[str, Any] ) -> None: """Fill envoy meter data from fixture.""" + if meters := json_fixture["data"].get("ctmeters"): + mocked_data.ctmeters = {} + [ + mocked_data.ctmeters.update({meter: EnvoyMeterData(**meter_data)}) + for meter, meter_data in meters.items() + ] + if meters := json_fixture["data"].get("ctmeters_phases"): + mocked_data.ctmeters_phases = {} + for meter, meter_data in meters.items(): + meter_phase_data: dict[str, EnvoyMeterData] = {} + [ + meter_phase_data.update({phase: EnvoyMeterData(**phase_data)}) + for phase, phase_data in meter_data.items() + ] + mocked_data.ctmeters_phases.update({meter: meter_phase_data}) + if item := json_fixture["data"].get("ctmeter_production"): mocked_data.ctmeter_production = EnvoyMeterData(**item) if item := json_fixture["data"].get("ctmeter_consumption"): diff --git a/tests/components/enphase_envoy/fixtures/envoy.json b/tests/components/enphase_envoy/fixtures/envoy.json index 85d8990b1ab..d177559a66f 100644 --- a/tests/components/enphase_envoy/fixtures/envoy.json +++ b/tests/components/enphase_envoy/fixtures/envoy.json @@ -27,6 +27,8 @@ "system_consumption_phases": null, "system_net_consumption_phases": null, "system_production_phases": null, + "ctmeters": {}, + "ctmeters_phases": {}, "ctmeter_production": null, "ctmeter_consumption": null, "ctmeter_storage": null, diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index 50f320edbc2..540dc154757 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -37,6 +37,39 @@ "system_consumption_phases": null, "system_net_consumption_phases": null, "system_production_phases": null, + "ctmeters": { + "production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "net-consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeters_phases": {}, "ctmeter_production": { "eid": "100000010", "timestamp": 1708006110, diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json index 5cc35d4050c..e83963f0a4d 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -87,6 +87,134 @@ "watts_now": 2341 }, "system_net_consumption_phases": null, + "ctmeters": { + "production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "net-consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeters_phases": { + "production": { + "L1": { + "eid": "100000011", + "timestamp": 1708006111, + "energy_delivered": 112341, + "energy_received": 123451, + "active_power": 20, + "power_factor": 0.12, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance"] + }, + "L2": { + "eid": "100000012", + "timestamp": 1708006112, + "energy_delivered": 112342, + "energy_received": 123452, + "active_power": 30, + "power_factor": 0.13, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["power-on-unused-phase"] + }, + "L3": { + "eid": "100000013", + "timestamp": 1708006113, + "energy_delivered": 112343, + "energy_received": 123453, + "active_power": 50, + "power_factor": 0.14, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": [] + } + }, + "net-consumption": { + "L1": { + "eid": "100000021", + "timestamp": 1708006121, + "energy_delivered": 212341, + "energy_received": 223451, + "active_power": 21, + "power_factor": 0.22, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000022", + "timestamp": 1708006122, + "energy_delivered": 212342, + "energy_received": 223452, + "active_power": 31, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000023", + "timestamp": 1708006123, + "energy_delivered": 212343, + "energy_received": 223453, + "active_power": 51, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + } + }, "ctmeter_production": { "eid": "100000010", "timestamp": 1708006110, diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json index b9951a4c6fa..42499ab400b 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -75,6 +75,134 @@ "watts_now": 2341 }, "system_net_consumption_phases": null, + "ctmeters": { + "production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "net-consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeters_phases": { + "production": { + "L1": { + "eid": "100000011", + "timestamp": 1708006111, + "energy_delivered": 112341, + "energy_received": 123451, + "active_power": 20, + "power_factor": 0.12, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance"] + }, + "L2": { + "eid": "100000012", + "timestamp": 1708006112, + "energy_delivered": 112342, + "energy_received": 123452, + "active_power": 30, + "power_factor": 0.13, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["power-on-unused-phase"] + }, + "L3": { + "eid": "100000013", + "timestamp": 1708006113, + "energy_delivered": 112343, + "energy_received": 123453, + "active_power": 50, + "power_factor": 0.14, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": [] + } + }, + "net-consumption": { + "L1": { + "eid": "100000021", + "timestamp": 1708006121, + "energy_delivered": 212341, + "energy_received": 223451, + "active_power": 21, + "power_factor": 0.22, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000022", + "timestamp": 1708006122, + "energy_delivered": 212342, + "energy_received": 223452, + "active_power": 31, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000023", + "timestamp": 1708006123, + "energy_delivered": 212343, + "energy_received": 223453, + "active_power": 51, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + } + }, "ctmeter_production": { "eid": "100000010", "timestamp": 1708006110, diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index e8e0fd8ac85..ec75a7994ae 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -151,6 +151,196 @@ "watts_now": 3234 } }, + "ctmeters": { + "production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "net-consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "storage": { + "eid": "100000030", + "timestamp": 1708006120, + "energy_delivered": 31234, + "energy_received": 32345, + "active_power": 103, + "power_factor": 0.23, + "voltage": 113, + "current": 0.4, + "frequency": 50.3, + "state": "enabled", + "measurement_type": "storage", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeters_phases": { + "production": { + "L1": { + "eid": "100000011", + "timestamp": 1708006111, + "energy_delivered": 112341, + "energy_received": 123451, + "active_power": 20, + "power_factor": 0.12, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance"] + }, + "L2": { + "eid": "100000012", + "timestamp": 1708006112, + "energy_delivered": 112342, + "energy_received": 123452, + "active_power": 30, + "power_factor": 0.13, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["power-on-unused-phase"] + }, + "L3": { + "eid": "100000013", + "timestamp": 1708006113, + "energy_delivered": 112343, + "energy_received": 123453, + "active_power": 50, + "power_factor": 0.14, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": [] + } + }, + "net-consumption": { + "L1": { + "eid": "100000021", + "timestamp": 1708006121, + "energy_delivered": 212341, + "energy_received": 223451, + "active_power": 21, + "power_factor": 0.22, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000022", + "timestamp": 1708006122, + "energy_delivered": 212342, + "energy_received": 223452, + "active_power": 31, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000023", + "timestamp": 1708006123, + "energy_delivered": 212343, + "energy_received": 223453, + "active_power": 51, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "storage": { + "L1": { + "eid": "100000031", + "timestamp": 1708006121, + "energy_delivered": 312341, + "energy_received": 323451, + "active_power": 22, + "power_factor": 0.32, + "voltage": 113, + "current": 0.4, + "frequency": 50.3, + "state": "enabled", + "measurement_type": "storage", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000032", + "timestamp": 1708006122, + "energy_delivered": 312342, + "energy_received": 323452, + "active_power": 33, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "storage", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000033", + "timestamp": 1708006123, + "energy_delivered": 312343, + "energy_received": 323453, + "active_power": 53, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "storage", + "metering_status": "normal", + "status_flags": [] + } + } + }, "ctmeter_production": { "eid": "100000010", "timestamp": 1708006110, diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index 5a9ca140f8c..edf9ef7db7e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -94,6 +94,134 @@ "watts_now": 3234 } }, + "ctmeters": { + "production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "net-consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeters_phases": { + "production": { + "L1": { + "eid": "100000011", + "timestamp": 1708006111, + "energy_delivered": 112341, + "energy_received": 123451, + "active_power": 20, + "power_factor": 0.12, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance"] + }, + "L2": { + "eid": "100000012", + "timestamp": 1708006112, + "energy_delivered": 112342, + "energy_received": 123452, + "active_power": 30, + "power_factor": 0.13, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["power-on-unused-phase"] + }, + "L3": { + "eid": "100000013", + "timestamp": 1708006113, + "energy_delivered": 112343, + "energy_received": 123453, + "active_power": 50, + "power_factor": 0.14, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": [] + } + }, + "net-consumption": { + "L1": { + "eid": "100000021", + "timestamp": 1708006121, + "energy_delivered": 212341, + "energy_received": 223451, + "active_power": 21, + "power_factor": 0.22, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000022", + "timestamp": 1708006122, + "energy_delivered": 212342, + "energy_received": 223452, + "active_power": 31, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000023", + "timestamp": 1708006123, + "energy_delivered": 212343, + "energy_received": 223453, + "active_power": 51, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + } + }, "ctmeter_production": { "eid": "100000010", "timestamp": 1708006110, diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 48b4de87867..aa799f5dd3c 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -32,6 +32,39 @@ "system_consumption_phases": null, "system_net_consumption_phases": null, "system_production_phases": null, + "ctmeters": { + "production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "total-consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "total-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeters_phases": {}, "ctmeter_production": { "eid": "100000010", "timestamp": 1708006110, diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index f9383d3b4f7..9fe709322af 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -187,13 +187,15 @@ def mock_client(mock_device_info) -> Generator[APIClient]: zeroconf_instance: Zeroconf = None, noise_psk: str | None = None, expected_name: str | None = None, - ): + timezone: str | None = None, + ) -> None: """Fake the client constructor.""" mock_client.host = address mock_client.port = port mock_client.password = password mock_client.zeroconf_instance = zeroconf_instance mock_client.noise_psk = noise_psk + mock_client.timezone = timezone return mock_client mock_client.side_effect = mock_constructor diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 6b7a1c64c9f..731acd0eb35 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -109,6 +109,8 @@ 'uses_password': False, 'voice_assistant_feature_flags': 0, 'webserver_port': 0, + 'zwave_home_id': 0, + 'zwave_proxy_feature_flags': 0, }), 'services': list([ ]), diff --git a/tests/components/esphome/test_analytics.py b/tests/components/esphome/test_analytics.py new file mode 100644 index 00000000000..f4de75b2ee0 --- /dev/null +++ b/tests/components/esphome/test_analytics.py @@ -0,0 +1,31 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.esphome import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={(DOMAIN, "test")}, + manufacturer="Test Manufacturer", + ) + + result = await async_devices_payload(hass) + assert DOMAIN not in result["integrations"] diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 2fdf53dc5ea..d6643c17d45 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -28,6 +28,7 @@ from homeassistant.components import ( tts, ) from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType +from homeassistant.components.assist_pipeline.pipeline import KEY_ASSIST_PIPELINE from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, AssistSatelliteEntityFeature, @@ -37,6 +38,7 @@ from homeassistant.components.assist_satellite import ( # pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.esphome.assist_satellite import VoiceAssistantUDPServer +from homeassistant.components.esphome.const import NO_WAKE_WORD from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -45,6 +47,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, intent as intent_helper from homeassistant.helpers.network import get_url +from homeassistant.setup import async_setup_component from .common import get_satellite_entity from .conftest import MockESPHomeDeviceType @@ -1737,7 +1740,7 @@ async def test_get_set_configuration( AssistSatelliteWakeWord("5678", "hey jarvis", ["en"]), ], active_wake_words=["1234"], - max_active_wake_words=1, + max_active_wake_words=2, ) mock_client.get_voice_assistant_configuration.return_value = expected_config @@ -1857,7 +1860,7 @@ async def test_wake_word_select( AssistSatelliteWakeWord("hey_mycroft", "Hey Mycroft", ["en"]), ], active_wake_words=["hey_jarvis"], - max_active_wake_words=1, + max_active_wake_words=2, ) mock_client.get_voice_assistant_configuration.return_value = device_config @@ -1884,7 +1887,7 @@ async def test_wake_word_select( assert satellite is not None assert satellite.async_get_configuration().active_wake_words == ["hey_jarvis"] - # Active wake word should be selected + # First wake word should be selected by default state = hass.states.get("select.test_wake_word") assert state is not None assert state.state == "Hey Jarvis" @@ -1908,3 +1911,177 @@ async def test_wake_word_select( # Satellite config should have been updated assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] + + # No secondary wake word should be selected by default + state = hass.states.get("select.test_wake_word_2") + assert state is not None + assert state.state == NO_WAKE_WORD + + # Changing the secondary select should add an active wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word_2", "option": "Hey Jarvis"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.test_wake_word_2") + assert state is not None + assert state.state == "Hey Jarvis" + + # Wait for device config to be updated + async with asyncio.timeout(1): + await configuration_set.wait() + + # Satellite config should have been updated + assert set(satellite.async_get_configuration().active_wake_words) == { + "okay_nabu", + "hey_jarvis", + } + + # Remove the secondary wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word_2", "option": NO_WAKE_WORD}, + blocking=True, + ) + await hass.async_block_till_done() + + async with asyncio.timeout(1): + await configuration_set.wait() + + # Only primary wake word remains + assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] + + # Remove the primary wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word", "option": NO_WAKE_WORD}, + blocking=True, + ) + await hass.async_block_till_done() + + async with asyncio.timeout(1): + await configuration_set.wait() + + # No active wake word remain + assert not satellite.async_get_configuration().active_wake_words + + +async def test_secondary_pipeline( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that the secondary pipeline is used when the secondary wake word is given.""" + assert await async_setup_component(hass, "assist_pipeline", {}) + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] + pipeline_id_to_name: dict[str, str] = {} + for pipeline_name in ("Primary Pipeline", "Secondary Pipeline"): + pipeline = await pipeline_data.pipeline_store.async_create_item( + { + "name": pipeline_name, + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + pipeline_id_to_name[pipeline.id] = pipeline_name + + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + AssistSatelliteWakeWord("hey_mycroft", "Hey Mycroft", ["en"]), + ], + active_wake_words=["hey_jarvis"], + max_active_wake_words=2, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + # Wrap mock so we can tell when it's done + configuration_set = asyncio.Event() + + async def wrapper(*args, **kwargs): + # Update device config because entity will request it after update + device_config.active_wake_words = kwargs["active_wake_words"] + configuration_set.set() + + mock_client.set_voice_assistant_configuration = AsyncMock(side_effect=wrapper) + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Set primary/secondary wake words and assistants + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word", "option": "Okay Nabu"}, + blocking=True, + ) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_assistant", "option": "Primary Pipeline"}, + blocking=True, + ) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word_2", "option": "Hey Jarvis"}, + blocking=True, + ) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_assistant_2", + "option": "Secondary Pipeline", + }, + blocking=True, + ) + await hass.async_block_till_done() + + async def get_pipeline(wake_word_phrase): + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + ) as mock_pipeline_from_audio_stream: + await satellite.handle_pipeline_start( + conversation_id="", + flags=0, + audio_settings=VoiceAssistantAudioSettings(), + wake_word_phrase=wake_word_phrase, + ) + + mock_pipeline_from_audio_stream.assert_called_once() + kwargs = mock_pipeline_from_audio_stream.call_args_list[0].kwargs + return pipeline_id_to_name[kwargs["pipeline_id"]] + + # Primary pipeline is the default + for wake_word_phrase in (None, "Okay Nabu"): + assert (await get_pipeline(wake_word_phrase)) == "Primary Pipeline" + + # Secondary pipeline requires secondary wake word + assert (await get_pipeline("Hey Jarvis")) == "Secondary Pipeline" + + # Primary pipeline should be restored after + assert (await get_pipeline(None)) == "Primary Pipeline" diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index c574764e3c9..216421cd8b0 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -75,11 +75,9 @@ async def test_climate_entity( swing_mode=ClimateSwingMode.BOTH, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -130,24 +128,32 @@ async def test_climate_entity_with_step_and_two_point( swing_mode=ClimateSwingMode.BOTH, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, - blocking=True, - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, + blocking=True, + ) + + mock_client.climate_command.assert_has_calls( + [ + call( + key=1, + target_temperature_high=25.0, + device_id=0, + ) + ] + ) + mock_client.climate_command.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, @@ -210,11 +216,9 @@ async def test_climate_entity_with_step_and_target_temp( swing_mode=ClimateSwingMode.BOTH, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -366,11 +370,9 @@ async def test_climate_entity_with_humidity( target_humidity=25.7, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -394,6 +396,162 @@ async def test_climate_entity_with_humidity( mock_client.climate_command.reset_mock() +async def test_climate_entity_with_heat( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a generic climate entity with heat.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supported_modes=[ClimateMode.COOL, ClimateMode.HEAT, ClimateMode.AUTO], + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.HEAT, + action=ClimateAction.HEATING, + current_temperature=18, + target_temperature=22, + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.state == HVACMode.HEAT + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [call(key=1, target_temperature_low=23, device_id=0)] + ) + mock_client.climate_command.reset_mock() + + +async def test_climate_entity_with_heat_cool( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a generic climate entity with heat.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supported_modes=[ClimateMode.COOL, ClimateMode.HEAT, ClimateMode.HEAT_COOL], + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.HEAT_COOL, + action=ClimateAction.HEATING, + current_temperature=18, + target_temperature=22, + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("climate.test_my_climate") + assert state is not None + assert state.state == HVACMode.HEAT_COOL + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_TARGET_TEMP_HIGH: 23, + ATTR_TARGET_TEMP_LOW: 20, + }, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [ + call( + key=1, + target_temperature_high=23, + target_temperature_low=20, + device_id=0, + ) + ] + ) + mock_client.climate_command.reset_mock() + + +async def test_climate_set_temperature_unsupported_mode( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test setting temperature in unsupported mode with two-point temperature support.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + supports_two_point_target_temperature=True, + supported_modes=[ClimateMode.HEAT, ClimateMode.COOL, ClimateMode.AUTO], + visual_min_temperature=10.0, + visual_max_temperature=30.0, + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.AUTO, + target_temperature=20, + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + with pytest.raises( + ServiceValidationError, + match="Setting target_temperature is only supported in heat or cool modes", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_TEMPERATURE: 25, + }, + blocking=True, + ) + + mock_client.climate_command.assert_not_called() + + async def test_climate_entity_with_inf_value( hass: HomeAssistant, mock_client: APIClient, @@ -429,11 +587,9 @@ async def test_climate_entity_with_inf_value( target_humidity=25.7, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -444,7 +600,7 @@ async def test_climate_entity_with_inf_value( assert attributes[ATTR_HUMIDITY] == 26 assert attributes[ATTR_MAX_HUMIDITY] == 30 assert attributes[ATTR_MIN_HUMIDITY] == 10 - assert ATTR_TEMPERATURE not in attributes + assert attributes[ATTR_TEMPERATURE] is None assert attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -490,11 +646,9 @@ async def test_climate_entity_attributes( swing_mode=ClimateSwingMode.BOTH, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") @@ -523,11 +677,9 @@ async def test_climate_entity_attribute_current_temperature_unsupported( current_temperature=30, ) ] - user_service = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, - user_service=user_service, states=states, ) state = hass.states.get("climate.test_my_climate") diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 1bedc6d79f8..fb7458a1a5b 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -3,7 +3,7 @@ from ipaddress import ip_address import json from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aioesphomeapi import ( APIClient, @@ -34,7 +34,9 @@ from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import discovery_flow 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 @@ -1184,6 +1186,42 @@ async def test_reauth_attempt_to_change_mac_aborts( } +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reauth_password_changed( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth when password has changed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: "old_password"}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.connect.side_effect = InvalidAuthAPIError("Invalid password") + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == { + "name": "Mock Title", + } + + mock_client.connect.side_effect = None + mock_client.connect.return_value = None + mock_client.device_info.return_value = DeviceInfo( + uses_password=True, name="test", mac_address="11:22:33:44:55:aa" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new_password"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_PASSWORD] == "new_password" + + @pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, @@ -1239,7 +1277,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( ) -> None: """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( - InvalidAuthAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), ) @@ -1458,6 +1496,45 @@ async def test_reauth_encryption_key_removed(hass: HomeAssistant) -> None: assert entry.data[CONF_NOISE_PSK] == "" +async def test_reauth_different_device_at_same_address( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth aborts when a different device is found at the same IP address.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "old_device", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + # Mock a different device at the same IP (different MAC address) + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, + name="new_device", + legacy_bluetooth_proxy_version=0, + # Different MAC address than the entry + mac_address="AA:BB:CC:DD:EE:FF", + esphome_version="1.0.0", + ) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unique_id_changed" + assert result["description_placeholders"] == { + "name": "old_device", + "host": "127.0.0.1", + "expected_mac": "11:22:33:44:55:aa", + "unexpected_mac": "aa:bb:cc:dd:ee:ff", + "unexpected_device_name": "new_device", + } + + @pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_discovery_dhcp_updates_host( hass: HomeAssistant, mock_client: APIClient @@ -2529,7 +2606,7 @@ async def test_discovery_dhcp_no_probe_same_host_port_none( service_info = DhcpServiceInfo( ip="192.168.43.183", hostname="test8266", - macaddress="11:22:33:44:55:aa", # Same MAC as configured + macaddress="1122334455aa", # Same MAC as configured ) result = await hass.config_entries.flow.async_init( @@ -2544,3 +2621,201 @@ async def test_discovery_dhcp_no_probe_same_host_port_none( # Host should remain unchanged assert entry.data[CONF_HOST] == "192.168.43.183" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_flow_starts_zwave_discovery( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test that the user flow starts Z-Wave JS discovery when device has Z-Wave capabilities.""" + # Mock device with Z-Wave capabilities + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + uses_password=False, + name="test-zwave-device", + mac_address="11:22:33:44:55:BB", + zwave_proxy_feature_flags=1, + zwave_home_id=1234567890, + ) + ) + mock_client.connected_address = "mock-connected-address" + + # Track flow.async_init calls and async_get calls + original_async_init = hass.config_entries.flow.async_init + original_async_get = hass.config_entries.flow.async_get + flow_init_calls = [] + zwave_flow_id = "mock-zwave-flow-id" + + async def track_async_init(*args, **kwargs): + flow_init_calls.append((args, kwargs)) + # For the Z-Wave flow, return a mock result with the flow_id + if args and args[0] == "zwave_js": + return {"flow_id": zwave_flow_id, "type": FlowResultType.FORM} + # Otherwise call the original + return await original_async_init(*args, **kwargs) + + def mock_async_get(flow_id: str): + # Return a mock flow for the Z-Wave flow_id + if flow_id == zwave_flow_id: + return MagicMock() + return original_async_get(flow_id) + + with ( + patch.object( + hass.config_entries.flow, "async_init", side_effect=track_async_init + ), + patch.object(hass.config_entries.flow, "async_get", side_effect=mock_async_get), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "192.168.1.100", CONF_PORT: 6053}, + ) + + # Verify the entry was created + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-zwave-device" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test-zwave-device", + } + + # First call is ESPHome flow, second should be Z-Wave flow + assert len(flow_init_calls) == 2 + zwave_call_args, zwave_call_kwargs = flow_init_calls[1] + assert zwave_call_args[0] == "zwave_js" + assert zwave_call_kwargs["context"] == { + "source": config_entries.SOURCE_ESPHOME, + "discovery_key": discovery_flow.DiscoveryKey( + domain="esphome", key="11:22:33:44:55:BB", version=1 + ), + } + assert zwave_call_kwargs["data"] == ESPHomeServiceInfo( + name="test-zwave-device", + zwave_home_id=1234567890, + ip_address="mock-connected-address", + port=6053, + noise_psk=None, + ) + + # Verify next_flow was set + assert result["next_flow"] == (config_entries.FlowType.CONFIG_FLOW, zwave_flow_id) + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_flow_no_zwave_discovery_without_capabilities( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test that the user flow does not start Z-Wave JS discovery when device has no Z-Wave capabilities.""" + # Mock device without Z-Wave capabilities + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + uses_password=False, + name="test-regular-device", + mac_address="11:22:33:44:55:CC", + ) + ) + + # Track flow.async_init calls + original_async_init = hass.config_entries.flow.async_init + flow_init_calls = [] + + async def track_async_init(*args, **kwargs): + flow_init_calls.append((args, kwargs)) + return await original_async_init(*args, **kwargs) + + with patch.object( + hass.config_entries.flow, "async_init", side_effect=track_async_init + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "192.168.1.101", CONF_PORT: 6053}, + ) + + # Verify the entry was created + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-regular-device" + + # Verify Z-Wave discovery flow was NOT started (only ESPHome flow) + assert len(flow_init_calls) == 1 + + # Verify next_flow was not set + assert "next_flow" not in result + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_flow_zwave_discovery_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test that the user flow handles Z-Wave discovery abort gracefully.""" + # Mock device with Z-Wave capabilities + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + uses_password=False, + name="test-zwave-device", + mac_address="11:22:33:44:55:DD", + zwave_proxy_feature_flags=1, + zwave_home_id=9876543210, + ) + ) + mock_client.connected_address = "192.168.1.102" + + # Track flow.async_init calls + original_async_init = hass.config_entries.flow.async_init + flow_init_calls = [] + + async def track_async_init(*args, **kwargs): + flow_init_calls.append((args, kwargs)) + # For the Z-Wave flow, return an ABORT result + if args and args[0] == "zwave_js": + return { + "type": FlowResultType.ABORT, + "reason": "already_configured", + } + # Otherwise call the original + return await original_async_init(*args, **kwargs) + + with patch.object( + hass.config_entries.flow, "async_init", side_effect=track_async_init + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "192.168.1.102", CONF_PORT: 6053}, + ) + + # Verify the ESPHome entry was still created despite Z-Wave flow aborting + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-zwave-device" + assert result["data"] == { + CONF_HOST: "192.168.1.102", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test-zwave-device", + } + + # Verify Z-Wave discovery flow was attempted + assert len(flow_init_calls) == 2 + zwave_call_args, zwave_call_kwargs = flow_init_calls[1] + assert zwave_call_args[0] == "zwave_js" + assert zwave_call_kwargs["context"]["source"] == config_entries.SOURCE_ESPHOME + assert zwave_call_kwargs["context"]["discovery_key"] == discovery_flow.DiscoveryKey( + domain=DOMAIN, + key="11:22:33:44:55:DD", + version=1, + ) + assert zwave_call_kwargs["data"] == ESPHomeServiceInfo( + name="test-zwave-device", + zwave_home_id=9876543210, + ip_address="192.168.1.102", + port=6053, + noise_psk=None, + ) + + # Verify next_flow was NOT set since Z-Wave flow aborted + assert "next_flow" not in result diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 340a10a86d1..36542b2bd09 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import patch -from aioesphomeapi import APIClient, DeviceInfo, InvalidAuthAPIError +from aioesphomeapi import APIClient, DeviceInfo, InvalidEncryptionKeyAPIError import pytest from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard @@ -194,7 +194,7 @@ async def test_new_dashboard_fix_reauth( ) -> None: """Test config entries waiting for reauth are triggered.""" mock_client.device_info.side_effect = ( - InvalidAuthAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"), ) diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index ebfe15d562f..76b2dc87ed3 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -146,6 +146,8 @@ async def test_diagnostics_with_bluetooth( "legacy_voice_assistant_version": 0, "voice_assistant_feature_flags": 0, "webserver_port": 0, + "zwave_home_id": 0, + "zwave_proxy_feature_flags": 0, }, "services": [], }, diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 044c3c7a8f1..a80c77eb5b2 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -1,5 +1,7 @@ """Test ESPHome entry data.""" +from unittest.mock import Mock, patch + from aioesphomeapi import ( APIClient, EntityCategory as ESPHomeEntityCategory, @@ -8,9 +10,11 @@ from aioesphomeapi import ( ) from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.entry_data import RuntimeEntryData from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -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 .conftest import MockGenericDeviceEntryType @@ -69,3 +73,50 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" + + +async def test_discover_zwave() -> None: + """Test ESPHome discovery of Z-Wave JS.""" + hass = Mock() + entry_data = RuntimeEntryData( + "mock-id", + "mock-title", + Mock( + connected_address="mock-client-address", + port=1234, + noise_psk=None, + ), + None, + ) + device_info = Mock( + mac_address="mock-device-info-mac", + zwave_proxy_feature_flags=1, + zwave_home_id=1234, + ) + device_info.name = "mock-device-infoname" + + with patch( + "homeassistant.helpers.discovery_flow.async_create_flow" + ) as mock_create_flow: + entry_data.async_on_connect( + hass, + device_info, + None, + ) + mock_create_flow.assert_called_once_with( + hass, + "zwave_js", + {"source": "esphome"}, + ESPHomeServiceInfo( + name="mock-device-infoname", + zwave_home_id=1234, + ip_address="mock-client-address", + port=1234, + noise_psk=None, + ), + discovery_key=discovery_flow.DiscoveryKey( + domain="esphome", + key="mock-device-info-mac", + version=1, + ), + ) diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index 93e9c0704c3..53639353f59 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -17,7 +17,7 @@ from homeassistant.components.lock import ( SERVICE_UNLOCK, LockState, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from .conftest import MockGenericDeviceEntryType @@ -140,3 +140,29 @@ async def test_lock_entity_supports_open( blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN, device_id=0)]) + + +async def test_lock_entity_none_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a generic lock entity with NONE state shows as unknown.""" + entity_info = [ + LockInfo( + object_id="mylock", + key=1, + name="my lock", + supports_open=False, + requires_code=False, + ) + ] + states = [LockEntityState(key=1, state=ESPHomeLockState.NONE)] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get("lock.test_my_lock") + assert state is not None + assert state.state == STATE_UNKNOWN # Should be unknown when ESPHome reports NONE diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 86dfb6e9ea3..319d70b4e42 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1455,6 +1455,37 @@ async def test_no_reauth_wrong_mac( ) +async def test_auth_error_during_on_connect_triggers_reauth( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test that InvalidAuthAPIError during on_connect triggers reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="11:22:33:44:55:aa", + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "wrong_password", + }, + ) + entry.add_to_hass(hass) + + mock_client.device_info_and_list_entities = AsyncMock( + side_effect=InvalidAuthAPIError("Invalid password!") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + assert flows[0]["context"]["entry_id"] == entry.entry_id + assert mock_client.disconnect.call_count >= 1 + + async def test_entry_missing_unique_id( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 14673f5ffb9..7de4dcd6aca 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -9,6 +9,7 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, AssistSatelliteWakeWord, ) +from homeassistant.components.esphome.const import NO_WAKE_WORD from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -32,6 +33,17 @@ async def test_pipeline_selector( assert state.state == "preferred" +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") +async def test_secondary_pipeline_selector( + hass: HomeAssistant, +) -> None: + """Test secondary assist pipeline selector.""" + + state = hass.states.get("select.test_assistant_2") + assert state is not None + assert state.state == "preferred" + + @pytest.mark.usefixtures("mock_voice_assistant_v1_entry") async def test_vad_sensitivity_select( hass: HomeAssistant, @@ -56,6 +68,16 @@ async def test_wake_word_select( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") +async def test_secondary_wake_word_select( + hass: HomeAssistant, +) -> None: + """Test that secondary wake word select is unavailable initially.""" + state = hass.states.get("select.test_wake_word_2") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + async def test_select_generic_entity( hass: HomeAssistant, mock_client: APIClient, @@ -117,10 +139,11 @@ async def test_wake_word_select_no_wake_words( assert satellite is not None assert not satellite.async_get_configuration().available_wake_words - # Select should be unavailable - state = hass.states.get("select.test_wake_word") - assert state is not None - assert state.state == STATE_UNAVAILABLE + # Selects should be unavailable + for entity_id in ("select.test_wake_word", "select.test_wake_word_2"): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE async def test_wake_word_select_zero_max_wake_words( @@ -151,10 +174,11 @@ async def test_wake_word_select_zero_max_wake_words( assert satellite is not None assert satellite.async_get_configuration().max_active_wake_words == 0 - # Select should be unavailable - state = hass.states.get("select.test_wake_word") - assert state is not None - assert state.state == STATE_UNAVAILABLE + # Selects should be unavailable + for entity_id in ("select.test_wake_word", "select.test_wake_word_2"): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE async def test_wake_word_select_no_active_wake_words( @@ -162,7 +186,7 @@ async def test_wake_word_select_no_active_wake_words( mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: - """Test wake word select uses first available wake word if none are active.""" + """Test wake word select has no wake word selected if none are active.""" device_config = AssistSatelliteConfiguration( available_wake_words=[ AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), @@ -186,7 +210,47 @@ async def test_wake_word_select_no_active_wake_words( assert satellite is not None assert not satellite.async_get_configuration().active_wake_words - # First available wake word should be selected + # No wake words should be selected + for entity_id in ("select.test_wake_word", "select.test_wake_word_2"): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == NO_WAKE_WORD + + +async def test_wake_word_select_first_active_wake_word( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test wake word select uses first available wake word if one is active.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + ], + active_wake_words=["okay_nabu"], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # First wake word should be selected state = hass.states.get("select.test_wake_word") assert state is not None assert state.state == "Okay Nabu" + + # Second wake word should not be selected + state_2 = hass.states.get("select.test_wake_word_2") + assert state_2 is not None + assert state_2.state == NO_WAKE_WORD diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 4528f1c8590..3aae5e3705f 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -139,7 +139,7 @@ async def test_auth_tokens_past( ) -> None: """Test credentials manager when cache contains expired data for this user.""" - dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) + _dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) # make this access token have expired in the past... test_data = TEST_STORAGE_DATA[idx].copy() # shallow copy is OK here diff --git a/tests/components/file/conftest.py b/tests/components/file/conftest.py index 5345a0d38d0..2e167310111 100644 --- a/tests/components/file/conftest.py +++ b/tests/components/file/conftest.py @@ -5,7 +5,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.file import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component @pytest.fixture @@ -30,3 +32,14 @@ def mock_is_allowed_path(hass: HomeAssistant, is_allowed: bool) -> Generator[Mag hass.config, "is_allowed_path", return_value=is_allowed ) as allowed_path_mock: yield allowed_path_mock + + +@pytest.fixture +async def setup_ha_file_integration(hass: HomeAssistant): + """Set up Home Assistant and load File integration.""" + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {}}, + ) + await hass.async_block_till_done() diff --git a/tests/components/file/fixtures/file_read.json b/tests/components/file/fixtures/file_read.json new file mode 100644 index 00000000000..5f745331620 --- /dev/null +++ b/tests/components/file/fixtures/file_read.json @@ -0,0 +1 @@ +{ "key": "value", "key1": "value1" } diff --git a/tests/components/file/fixtures/file_read.not_json b/tests/components/file/fixtures/file_read.not_json new file mode 100644 index 00000000000..07967a9afa2 --- /dev/null +++ b/tests/components/file/fixtures/file_read.not_json @@ -0,0 +1 @@ +{ "key": "value", "key1": value1 } diff --git a/tests/components/file/fixtures/file_read.not_yaml b/tests/components/file/fixtures/file_read.not_yaml new file mode 100644 index 00000000000..a7e5ad397dc --- /dev/null +++ b/tests/components/file/fixtures/file_read.not_yaml @@ -0,0 +1,4 @@ +test: + - element: "X" + - element: "Y" + unexpected: "Z" diff --git a/tests/components/file/fixtures/file_read.yaml b/tests/components/file/fixtures/file_read.yaml new file mode 100644 index 00000000000..cb2a2c9b1f9 --- /dev/null +++ b/tests/components/file/fixtures/file_read.yaml @@ -0,0 +1,5 @@ +mylist: + - name: list_item_1 + id: 1 + - name: list_item_2 + id: 2 diff --git a/tests/components/file/fixtures/file_read_list.yaml b/tests/components/file/fixtures/file_read_list.yaml new file mode 100644 index 00000000000..3e4271b3941 --- /dev/null +++ b/tests/components/file/fixtures/file_read_list.yaml @@ -0,0 +1,4 @@ +- name: list_item_1 + id: 1 +- name: list_item_2 + id: 2 diff --git a/tests/components/file/snapshots/test_services.ambr b/tests/components/file/snapshots/test_services.ambr new file mode 100644 index 00000000000..daa7c3990fa --- /dev/null +++ b/tests/components/file/snapshots/test_services.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_read_file[tests/components/file/fixtures/file_read.json-json] + dict({ + 'data': dict({ + 'key': 'value', + 'key1': 'value1', + }), + }) +# --- +# name: test_read_file[tests/components/file/fixtures/file_read.yaml-yaml] + dict({ + 'data': dict({ + 'mylist': list([ + dict({ + 'id': 1, + 'name': 'list_item_1', + }), + dict({ + 'id': 2, + 'name': 'list_item_2', + }), + ]), + }), + }) +# --- +# name: test_read_file[tests/components/file/fixtures/file_read_list.yaml-yaml] + dict({ + 'data': list([ + dict({ + 'id': 1, + 'name': 'list_item_1', + }), + dict({ + 'id': 2, + 'name': 'list_item_2', + }), + ]), + }) +# --- diff --git a/tests/components/file/test_services.py b/tests/components/file/test_services.py new file mode 100644 index 00000000000..9b7198b9967 --- /dev/null +++ b/tests/components/file/test_services.py @@ -0,0 +1,147 @@ +"""The tests for the notify file platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.file import DOMAIN +from homeassistant.components.file.services import ( + ATTR_FILE_ENCODING, + ATTR_FILE_NAME, + SERVICE_READ_FILE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + + +@pytest.mark.parametrize( + ("file_name", "file_encoding"), + [ + ("tests/components/file/fixtures/file_read.json", "json"), + ("tests/components/file/fixtures/file_read.yaml", "yaml"), + ("tests/components/file/fixtures/file_read_list.yaml", "yaml"), + ], +) +async def test_read_file( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, + file_name: str, + file_encoding: str, + snapshot: SnapshotAssertion, +) -> None: + """Test reading files in supported formats.""" + result = await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: file_encoding, + }, + blocking=True, + return_response=True, + ) + assert result == snapshot + + +async def test_read_file_disallowed_path( + hass: HomeAssistant, + setup_ha_file_integration, +) -> None: + """Test reading in a disallowed path generates error.""" + file_name = "tests/components/file/fixtures/file_read.json" + + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "json", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(sve.value) + assert sve.value.translation_key == "no_access_to_path" + assert sve.value.translation_domain == DOMAIN + + +async def test_read_file_bad_encoding_option( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, +) -> None: + """Test handling error if an invalid encoding is specified.""" + file_name = "tests/components/file/fixtures/file_read.json" + + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "invalid", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(sve.value) + assert "invalid" in str(sve.value) + assert sve.value.translation_key == "unsupported_file_encoding" + assert sve.value.translation_domain == DOMAIN + + +@pytest.mark.parametrize( + ("file_name", "file_encoding"), + [ + ("tests/components/file/fixtures/file_read.not_json", "json"), + ("tests/components/file/fixtures/file_read.not_yaml", "yaml"), + ], +) +async def test_read_file_decoding_error( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, + file_name: str, + file_encoding: str, +) -> None: + """Test decoding errors are handled correctly.""" + with pytest.raises(HomeAssistantError) as hae: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: file_encoding, + }, + blocking=True, + return_response=True, + ) + assert file_name in str(hae.value) + assert file_encoding in str(hae.value) + assert hae.value.translation_key == "file_decoding" + assert hae.value.translation_domain == DOMAIN + + +async def test_read_file_dne( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, +) -> None: + """Test handling error if file does not exist.""" + file_name = "tests/components/file/fixtures/file_dne.yaml" + + with pytest.raises(HomeAssistantError) as hae: + _ = await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "yaml", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(hae.value) diff --git a/tests/components/firefly_iii/__init__.py b/tests/components/firefly_iii/__init__.py new file mode 100644 index 00000000000..7ae33ed0ce0 --- /dev/null +++ b/tests/components/firefly_iii/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Firefly III 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/firefly_iii/conftest.py b/tests/components/firefly_iii/conftest.py new file mode 100644 index 00000000000..18250624ca7 --- /dev/null +++ b/tests/components/firefly_iii/conftest.py @@ -0,0 +1,95 @@ +"""Common fixtures for the Firefly III tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyfirefly.models import About, Account, Bill, Budget, Category, Currency +import pytest + +from homeassistant.components.firefly_iii.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_value_fixture, +) + +MOCK_TEST_CONFIG = { + CONF_URL: "https://127.0.0.1:8080/", + CONF_API_KEY: "test_api_key", + CONF_VERIFY_SSL: True, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.firefly_iii.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_firefly_client() -> Generator[AsyncMock]: + """Mock Firefly client with dynamic exception injection support.""" + with ( + patch( + "homeassistant.components.firefly_iii.config_flow.Firefly" + ) as mock_client, + patch( + "homeassistant.components.firefly_iii.coordinator.Firefly", new=mock_client + ), + ): + client = mock_client.return_value + + client.get_about = AsyncMock( + return_value=About.from_dict(load_json_value_fixture("about.json", DOMAIN)) + ) + client.get_accounts = AsyncMock( + return_value=[ + Account.from_dict(account) + for account in load_json_array_fixture("accounts.json", DOMAIN) + ] + ) + client.get_categories = AsyncMock( + return_value=[ + Category.from_dict(category) + for category in load_json_array_fixture("categories.json", DOMAIN) + ] + ) + client.get_category = AsyncMock( + return_value=Category.from_dict( + load_json_value_fixture("category.json", DOMAIN) + ) + ) + client.get_currency_primary = AsyncMock( + return_value=Currency.from_dict( + load_json_value_fixture("primary_currency.json", DOMAIN) + ) + ) + client.get_budgets = AsyncMock( + return_value=[ + Budget.from_dict(budget) + for budget in load_json_array_fixture("budgets.json", DOMAIN) + ] + ) + client.get_bills = AsyncMock( + return_value=[ + Bill.from_dict(bill) + for bill in load_json_array_fixture("bills.json", DOMAIN) + ] + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Firefly III test", + data=MOCK_TEST_CONFIG, + entry_id="firefly_iii_test_entry_123", + ) diff --git a/tests/components/firefly_iii/fixtures/about.json b/tests/components/firefly_iii/fixtures/about.json new file mode 100644 index 00000000000..4d15af129df --- /dev/null +++ b/tests/components/firefly_iii/fixtures/about.json @@ -0,0 +1,7 @@ +{ + "version": "5.8.0-alpha.1", + "api_version": "5.8.0-alpha.1", + "php_version": "8.1.5", + "os": "Linux", + "driver": "mysql" +} diff --git a/tests/components/firefly_iii/fixtures/accounts.json b/tests/components/firefly_iii/fixtures/accounts.json new file mode 100644 index 00000000000..39c1f671f1e --- /dev/null +++ b/tests/components/firefly_iii/fixtures/accounts.json @@ -0,0 +1,178 @@ +[ + { + "type": "accounts", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "active": false, + "order": 1, + "name": "My checking account", + "type": "asset", + "account_role": "defaultAsset", + "currency_id": "12", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "12", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "current_balance": "123.45", + "native_current_balance": "123.45", + "current_balance_date": "2018-09-17T12:46:47+01:00", + "notes": "Some example notes", + "monthly_payment_date": "2018-09-17T12:46:47+01:00", + "credit_card_type": "monthlyFull", + "account_number": "7009312345678", + "iban": "GB98MIDL07009312345678", + "bic": "BOFAUS3N", + "virtual_balance": "123.45", + "native_virtual_balance": "123.45", + "opening_balance": "-1012.12", + "native_opening_balance": "-1012.12", + "opening_balance_date": "2018-09-17T12:46:47+01:00", + "liability_type": "loan", + "liability_direction": "credit", + "interest": "5.3", + "interest_period": "monthly", + "current_debt": "1012.12", + "include_net_worth": true, + "longitude": 5.916667, + "latitude": 51.983333, + "zoom_level": 6 + } + }, + { + "type": "accounts", + "id": "3", + "attributes": { + "created_at": "2019-01-01T10:00:00+01:00", + "updated_at": "2020-01-01T10:00:00+01:00", + "active": true, + "order": 2, + "name": "Savings Account", + "type": "expense", + "account_role": "savingsAsset", + "currency_id": "13", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "13", + "native_currency_code": "USD", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "current_balance": "5000.00", + "native_current_balance": "5000.00", + "current_balance_date": "2020-01-01T10:00:00+01:00", + "notes": "Main savings account", + "monthly_payment_date": null, + "credit_card_type": null, + "account_number": "1234567890", + "iban": "US12345678901234567890", + "bic": "CITIUS33", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "1000.00", + "native_opening_balance": "1000.00", + "opening_balance_date": "2019-01-01T10:00:00+01:00", + "liability_type": null, + "liability_direction": null, + "interest": "1.2", + "interest_period": "yearly", + "current_debt": null, + "include_net_worth": true, + "longitude": -74.006, + "latitude": 40.7128, + "zoom_level": 8 + } + }, + { + "type": "accounts", + "id": "4", + "attributes": { + "created_at": "2021-05-10T09:30:00+01:00", + "updated_at": "2022-05-10T09:30:00+01:00", + "active": true, + "order": 3, + "name": "Credit Card", + "type": "liability", + "account_role": "creditCard", + "currency_id": "14", + "currency_code": "GBP", + "currency_symbol": "£", + "currency_decimal_places": 2, + "native_currency_id": "14", + "native_currency_code": "GBP", + "native_currency_symbol": "£", + "native_currency_decimal_places": 2, + "current_balance": "-250.00", + "native_current_balance": "-250.00", + "current_balance_date": "2022-05-10T09:30:00+01:00", + "notes": "Credit card account", + "monthly_payment_date": "2022-05-15T09:30:00+01:00", + "credit_card_type": "monthlyFull", + "account_number": "9876543210", + "iban": "GB29NWBK60161331926819", + "bic": "NWBKGB2L", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "0.00", + "native_opening_balance": "0.00", + "opening_balance_date": "2021-05-10T09:30:00+01:00", + "liability_type": "credit", + "liability_direction": "debit", + "interest": "19.99", + "interest_period": "monthly", + "current_debt": "250.00", + "include_net_worth": false, + "longitude": 0.1278, + "latitude": 51.5074, + "zoom_level": 10 + } + }, + { + "type": "accounts", + "id": "4", + "attributes": { + "created_at": "2021-05-10T09:30:00+01:00", + "updated_at": "2022-05-10T09:30:00+01:00", + "active": true, + "order": 3, + "name": "Credit Card", + "type": "revenue", + "account_role": "creditCard", + "currency_id": "14", + "currency_code": "GBP", + "currency_symbol": "£", + "currency_decimal_places": 2, + "native_currency_id": "14", + "native_currency_code": "GBP", + "native_currency_symbol": "£", + "native_currency_decimal_places": 2, + "current_balance": "-250.00", + "native_current_balance": "-250.00", + "current_balance_date": "2022-05-10T09:30:00+01:00", + "notes": "Credit card account", + "monthly_payment_date": "2022-05-15T09:30:00+01:00", + "credit_card_type": "monthlyFull", + "account_number": "9876543210", + "iban": "GB29NWBK60161331926819", + "bic": "NWBKGB2L", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "0.00", + "native_opening_balance": "0.00", + "opening_balance_date": "2021-05-10T09:30:00+01:00", + "liability_type": "credit", + "liability_direction": "debit", + "interest": "19.99", + "interest_period": "monthly", + "current_debt": "250.00", + "include_net_worth": false, + "longitude": 0.1278, + "latitude": 51.5074, + "zoom_level": 10 + } + } +] diff --git a/tests/components/firefly_iii/fixtures/bills.json b/tests/components/firefly_iii/fixtures/bills.json new file mode 100644 index 00000000000..a59ee410581 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/bills.json @@ -0,0 +1,44 @@ +[ + { + "type": "bills", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "currency_id": "5", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "name": "Rent", + "amount_min": "123.45", + "amount_max": "123.45", + "native_amount_min": "123.45", + "native_amount_max": "123.45", + "date": "2018-09-17T12:46:47+01:00", + "end_date": "2018-09-17T12:46:47+01:00", + "extension_date": "2018-09-17T12:46:47+01:00", + "repeat_freq": "monthly", + "skip": 0, + "active": true, + "order": 1, + "notes": "Some example notes", + "next_expected_match": "2018-09-17T12:46:47+01:00", + "next_expected_match_diff": "today", + "object_group_id": "5", + "object_group_order": 5, + "object_group_title": "Example Group", + "pay_dates": ["2018-09-17T12:46:47+01:00"], + "paid_dates": [ + { + "transaction_group_id": "123", + "transaction_journal_id": "123", + "date": "2018-09-17T12:46:47+01:00" + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/budgets.json b/tests/components/firefly_iii/fixtures/budgets.json new file mode 100644 index 00000000000..39bd152e958 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/budgets.json @@ -0,0 +1,35 @@ +[ + { + "type": "budgets", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Bills", + "active": false, + "notes": "Some notes", + "order": 5, + "auto_budget_type": "reset", + "currency_id": "12", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "auto_budget_amount": "-1012.12", + "native_auto_budget_amount": "-1012.12", + "auto_budget_period": "monthly", + "spent": [ + { + "sum": "123.45", + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2 + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/categories.json b/tests/components/firefly_iii/fixtures/categories.json new file mode 100644 index 00000000000..ee7c7df2f58 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/categories.json @@ -0,0 +1,34 @@ +[ + { + "type": "categories", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Lunch", + "notes": "Some example notes", + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "spent": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "-12423.45" + } + ], + "earned": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "123.45" + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/category.json b/tests/components/firefly_iii/fixtures/category.json new file mode 100644 index 00000000000..415edb6ef0a --- /dev/null +++ b/tests/components/firefly_iii/fixtures/category.json @@ -0,0 +1,32 @@ +{ + "type": "categories", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Lunch", + "notes": "Some example notes", + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "spent": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "-12423.45" + } + ], + "earned": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "123.45" + } + ] + } +} diff --git a/tests/components/firefly_iii/fixtures/primary_currency.json b/tests/components/firefly_iii/fixtures/primary_currency.json new file mode 100644 index 00000000000..38472f84c55 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/primary_currency.json @@ -0,0 +1,15 @@ +{ + "type": "currencies", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "enabled": true, + "default": false, + "native": false, + "code": "AMS", + "name": "Ankh-Morpork dollar", + "symbol": "AM$", + "decimal_places": 2 + } +} diff --git a/tests/components/firefly_iii/snapshots/test_sensor.ambr b/tests/components/firefly_iii/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bccd54746ec --- /dev/null +++ b/tests/components/firefly_iii/snapshots/test_sensor.ambr @@ -0,0 +1,216 @@ +# serializer version: 1 +# name: test_all_entities[sensor.firefly_iii_test_credit_card-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.firefly_iii_test_credit_card', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:hand-coin', + 'original_name': 'Credit Card', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_4', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_credit_card-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Credit Card', + 'icon': 'mdi:hand-coin', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_credit_card', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-250.00', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_lunch-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.firefly_iii_test_lunch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lunch', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'category', + 'unique_id': 'None_category_2', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_lunch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Lunch', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_lunch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12300.0', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_my_checking_account-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.firefly_iii_test_my_checking_account', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:account-cash', + 'original_name': 'My checking account', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_2', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_my_checking_account-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test My checking account', + 'icon': 'mdi:account-cash', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_my_checking_account', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.45', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_savings_account-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.firefly_iii_test_savings_account', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:cash-minus', + 'original_name': 'Savings Account', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_3', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_savings_account-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Savings Account', + 'icon': 'mdi:cash-minus', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_savings_account', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.00', + }) +# --- diff --git a/tests/components/firefly_iii/test_config_flow.py b/tests/components/firefly_iii/test_config_flow.py new file mode 100644 index 00000000000..afe0a95831e --- /dev/null +++ b/tests/components/firefly_iii/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Firefly III config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyfirefly.exceptions import ( + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import pytest + +from homeassistant.components.firefly_iii.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_TEST_CONFIG + +from tests.common import MockConfigEntry + +MOCK_USER_SETUP = { + CONF_URL: "https://127.0.0.1:8080/", + CONF_API_KEY: "test_api_key", + CONF_VERIFY_SSL: True, +} + + +async def test_form_and_flow( + hass: HomeAssistant, + mock_firefly_client: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Test we get the form and can complete the flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:8080/" + assert result["data"] == MOCK_TEST_CONFIG + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + FireflyAuthenticationError, + "invalid_auth", + ), + ( + FireflyConnectionError, + "cannot_connect", + ), + ( + FireflyTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_setup_entry: MagicMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions.""" + mock_firefly_client.get_about.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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=MOCK_USER_SETUP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_firefly_client.get_about.side_effect = None + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:8080/" + assert result["data"] == MOCK_TEST_CONFIG + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle duplicate entries.""" + 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["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_flow_reauth( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full flow of the config flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # There is no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + 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" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + FireflyAuthenticationError, + "invalid_auth", + ), + ( + FireflyConnectionError, + "cannot_connect", + ), + ( + FireflyTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions in the reauth flow.""" + mock_config_entry.add_to_hass(hass) + mock_firefly_client.get_about.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + # Now test that we can recover from the error + mock_firefly_client.get_about.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" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/firefly_iii/test_init.py b/tests/components/firefly_iii/test_init.py new file mode 100644 index 00000000000..fa7ab788eb9 --- /dev/null +++ b/tests/components/firefly_iii/test_init.py @@ -0,0 +1,38 @@ +"""Tests for the Firefly III integration.""" + +from unittest.mock import AsyncMock + +from pyfirefly.exceptions import ( + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (FireflyAuthenticationError("bad creds"), ConfigEntryState.SETUP_ERROR), + (FireflyConnectionError("cannot connect"), ConfigEntryState.SETUP_RETRY), + (FireflyTimeoutError("timeout"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test the _async_setup.""" + mock_firefly_client.get_about.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state == expected_state diff --git a/tests/components/firefly_iii/test_sensor.py b/tests/components/firefly_iii/test_sensor.py new file mode 100644 index 00000000000..aa674c27910 --- /dev/null +++ b/tests/components/firefly_iii/test_sensor.py @@ -0,0 +1,70 @@ +"""Tests for the Firefly III sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyfirefly.exceptions import ( + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.firefly_iii.coordinator import DEFAULT_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.firefly_iii._PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("exception"), + [ + FireflyAuthenticationError("bad creds"), + FireflyConnectionError("cannot connect"), + FireflyTimeoutError("timeout"), + ], +) +async def test_refresh_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test entities go unavailable after coordinator refresh failures.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_firefly_client.get_accounts.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("sensor.firefly_iii_test_credit_card") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr index c3c3b8f185d..8236540654d 100644 --- a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr @@ -216,7 +216,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -254,6 +256,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Exhaust air temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -269,7 +272,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -307,6 +312,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Extract air temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -482,7 +488,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -520,6 +528,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Outside air temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -591,7 +600,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -629,6 +640,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Room temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -748,7 +760,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -786,6 +800,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Supply air temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py index 43616693303..a7a5b1abe48 100644 --- a/tests/components/foscam/conftest.py +++ b/tests/components/foscam/conftest.py @@ -75,7 +75,15 @@ def setup_mock_foscam_camera(mock_foscam_camera): mock_foscam_camera.getWdrMode.return_value = (0, {"mode": "0"}) mock_foscam_camera.getHdrMode.return_value = (0, {"mode": "0"}) mock_foscam_camera.get_motion_detect_config.return_value = (0, 1) - + mock_foscam_camera.getSWCapabilities.return_value = ( + 0, + { + "swCapabilities1": "100", + "swCapbilities2": "100", + "swCapbilities3": "100", + "swCapbilities4": "100", + }, + ) return mock_foscam_camera mock_foscam_camera.side_effect = configure_mock_on_init diff --git a/tests/components/foscam/snapshots/test_number.ambr b/tests/components/foscam/snapshots/test_number.ambr new file mode 100644 index 00000000000..74294c7306a --- /dev/null +++ b/tests/components/foscam/snapshots/test_number.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_number_entities[number.mock_title_device_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_title_device_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': 'Device volume', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_volume', + 'unique_id': '123ABC_device_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[number.mock_title_device_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Device volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_title_device_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_entities[number.mock_title_speak_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_title_speak_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': 'Speak volume', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'speak_volume', + 'unique_id': '123ABC_speak_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[number.mock_title_speak_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Speak volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_title_speak_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/foscam/test_number.py b/tests/components/foscam/test_number.py new file mode 100644 index 00000000000..94088c94895 --- /dev/null +++ b/tests/components/foscam/test_number.py @@ -0,0 +1,62 @@ +"""Test the Foscam number platform.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.foscam.const import DOMAIN +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_mock_foscam_camera +from .const import ENTRY_ID, VALID_CONFIG + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_number_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test creation of number entities.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) + entry.add_to_hass(hass) + + with ( + # Mock a valid camera instance + patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, + patch("homeassistant.components.foscam.PLATFORMS", [Platform.NUMBER]), + ): + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_setting_number(hass: HomeAssistant) -> None: + """Test setting a number entity calls the correct method on the camera.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) + entry.add_to_hass(hass) + + with patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.mock_title_device_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + mock_foscam_camera.setAudioVolume.assert_called_once_with(42) diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index fa92fa37c04..017328ea0eb 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -27,6 +27,26 @@ class FritzServiceMock(Service): self.serviceId = serviceId +class FritzResponseMock: + """Response mocking.""" + + def json(self): + """Mock json method.""" + return {"CPUTEMP": "69,68,67"} + + +class FritzHttpMock: + """FritzHttp mocking.""" + + def __init__(self) -> None: + """Init Mocking class.""" + self.router_url = "http://fritz.box" + + def call_url(self, *args, **kwargs): + """Mock call_url method.""" + return FritzResponseMock() + + class FritzConnectionMock: """FritzConnection mocking.""" @@ -39,6 +59,7 @@ class FritzConnectionMock: srv: FritzServiceMock(serviceId=srv, actions=actions) for srv, actions in services.items() } + self.http_interface = FritzHttpMock() LOGGER.debug("-" * 80) LOGGER.debug("FritzConnectionMock - services: %s", self.services) diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index c2ca866ceb6..dead09cae4a 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -12,6 +12,11 @@ }), ]), 'connection_type': 'WANPPPConnection', + 'cpu_temperatures': list([ + 69, + 68, + 67, + ]), 'current_firmware': '7.29', 'discovered_services': list([ 'DeviceInfo1', diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 4efae5951e8..ae3bf6d6889 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -825,3 +825,59 @@ 'state': '3.4', }) # --- +# name: test_sensor_setup[sensor.mock_title_cpu_temperature-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': , + 'entity_id': 'sensor.mock_title_cpu_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CPU temperature', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cpu_temperature', + 'unique_id': '1C:ED:6F:12:34:11-cpu_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_cpu_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Title CPU temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_cpu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '69', + }) +# --- diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index a6c35513dc3..e5cd6fa3089 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -1,6 +1,7 @@ """The tests for Home Assistant frontend.""" from collections.abc import Generator +from contextlib import nullcontext from http import HTTPStatus from pathlib import Path import re @@ -410,8 +411,72 @@ async def test_themes_reload_themes( @pytest.mark.usefixtures("frontend") +@pytest.mark.parametrize( + ("invalid_theme", "error", "log"), + [ + ( + { + "invalid0": "blue", + }, + "expected a dictionary", + None, + ), + ( + { + "invalid1": { + "primary-color": "black", + "modes": "light:{} dark:{}", + } + }, + None, + "expected a dictionary", + ), + ( + { + "invalid2": None, + }, + "expected a dictionary", + None, + ), + ( + { + "invalid3": { + "primary-color": "black", + "modes": {}, + } + }, + None, + "must contain at least one of light, dark", + ), + ( + { + "invalid4": { + "primary-color": "black", + "modes": None, + } + }, + "string value is None for dictionary value", + None, + ), + ( + { + "invalid5": { + "primary-color": "black", + "modes": {"light": {}, "dank": {}}, + } + }, + "extra keys not allowed.*dank", + None, + ), + ], +) async def test_themes_reload_invalid( - hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket + hass: HomeAssistant, + themes_ws_client: MockHAClientWebSocket, + invalid_theme: dict, + error: str | None, + log: str | None, + caplog: pytest.LogCaptureFixture, ) -> None: """Test frontend.reload_themes service with an invalid theme.""" @@ -424,17 +489,26 @@ async def test_themes_reload_invalid( with ( patch( "homeassistant.components.frontend.async_hass_config_yaml", - return_value={DOMAIN: {CONF_THEMES: {"sad": "blue"}}}, + return_value={DOMAIN: {CONF_THEMES: invalid_theme}}, ), - pytest.raises(HomeAssistantError, match="Failed to reload themes"), + pytest.raises(HomeAssistantError, match=rf"Failed to reload themes.*{error}") + if error is not None + else nullcontext(), ): await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + if log is not None: + assert log in caplog.text + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) msg = await themes_ws_client.receive_json() - assert msg["result"]["themes"] == {"happy": {"primary-color": "pink"}} + expected_themes = {"happy": {"primary-color": "pink"}} + if error is None: + expected_themes = {} + + assert msg["result"]["themes"] == expected_themes assert msg["result"]["default_theme"] == "default" diff --git a/tests/components/fully_kiosk/test_button.py b/tests/components/fully_kiosk/test_button.py index 4652ee96047..e263cbc7082 100644 --- a/tests/components/fully_kiosk/test_button.py +++ b/tests/components/fully_kiosk/test_button.py @@ -74,6 +74,17 @@ async def test_buttons( ) assert len(mock_fully_kiosk.loadStartUrl.mock_calls) == 1 + entry = entity_registry.async_get("button.amazon_fire_clear_browser_cache") + assert entry + assert entry.unique_id == "abcdef-123456-clearCache" + await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_clear_browser_cache"}, + blocking=True, + ) + assert len(mock_fully_kiosk.clearCache.mock_calls) == 1 + assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index e77e61346b6..7b748096ca5 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -1,6 +1,6 @@ """The tests for the go2rtc component.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable import logging from typing import NamedTuple from unittest.mock import AsyncMock, Mock, patch @@ -29,6 +29,11 @@ from homeassistant.components.camera import ( WebRTCSendMessage, async_get_image, ) +from homeassistant.components.camera.const import DATA_CAMERA_PREFS +from homeassistant.components.camera.prefs import ( + CameraPreferences, + DynamicStreamSettings, +) from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN from homeassistant.components.go2rtc import HomeAssistant, WebRTCProvider from homeassistant.components.go2rtc.const import ( @@ -37,6 +42,7 @@ from homeassistant.components.go2rtc.const import ( DOMAIN, RECOMMENDED_VERSION, ) +from homeassistant.components.stream import Orientation from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_URL from homeassistant.exceptions import HomeAssistantError @@ -696,3 +702,208 @@ async def test_generic_workaround( f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", ], ) + + +async def _test_camera_orientation( + hass: HomeAssistant, + camera: MockCamera, + orientation: Orientation, + rest_client: AsyncMock, + expected_stream_source: str, + camera_fn: Callable[[HomeAssistant, MockCamera], Awaitable[None]], +) -> None: + """Test camera orientation handling in go2rtc provider.""" + # Ensure go2rtc provider is initialized + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs + + # Set the specific orientation for this test by directly setting the dynamic stream settings + test_settings = DynamicStreamSettings(orientation=orientation, preload_stream=False) + prefs._dynamic_stream_settings_by_entity_id[camera.entity_id] = test_settings + + # Call the camera function that should trigger stream update + await camera_fn(hass, camera) + + # Verify the stream was configured correctly + rest_client.streams.add.assert_called_once_with( + camera.entity_id, + [ + expected_stream_source, + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], + ) + + +async def _test_camera_orientation_webrtc( + hass: HomeAssistant, + camera: MockCamera, + orientation: Orientation, + rest_client: AsyncMock, + expected_stream_source: str, +) -> None: + """Test camera orientation handling in go2rtc provider on WebRTC stream.""" + + async def camera_fn(hass: HomeAssistant, camera: MockCamera) -> None: + """Mock function to simulate WebRTC offer handling.""" + receive_message_callback = Mock() + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "test_session", receive_message_callback + ) + + await _test_camera_orientation( + hass, + camera, + orientation, + rest_client, + expected_stream_source, + camera_fn, + ) + + +async def _test_camera_orientation_get_image( + hass: HomeAssistant, + camera: MockCamera, + orientation: Orientation, + rest_client: AsyncMock, + expected_stream_source: str, +) -> None: + """Test camera orientation handling in go2rtc provider on get_image.""" + + async def camera_fn(hass: HomeAssistant, camera: MockCamera) -> None: + """Mock function to simulate get_image handling.""" + rest_client.get_jpeg_snapshot.return_value = b"image_bytes" + # Get image which should trigger stream update with orientation + await async_get_image(hass, camera.entity_id) + + await _test_camera_orientation( + hass, + camera, + orientation, + rest_client, + expected_stream_source, + camera_fn, + ) + + +@pytest.mark.usefixtures("init_integration", "ws_client") +@pytest.mark.parametrize( + ("orientation", "expected_stream_source"), + [ + ( + Orientation.MIRROR, + "ffmpeg:rtsp://stream#video=h264#audio=copy#raw=-vf hflip", + ), + ( + Orientation.ROTATE_180, + "ffmpeg:rtsp://stream#video=h264#audio=copy#rotate=180", + ), + (Orientation.FLIP, "ffmpeg:rtsp://stream#video=h264#audio=copy#raw=-vf vflip"), + ( + Orientation.ROTATE_LEFT_AND_FLIP, + "ffmpeg:rtsp://stream#video=h264#audio=copy#raw=-vf transpose=2,vflip", + ), + ( + Orientation.ROTATE_LEFT, + "ffmpeg:rtsp://stream#video=h264#audio=copy#rotate=-90", + ), + ( + Orientation.ROTATE_RIGHT_AND_FLIP, + "ffmpeg:rtsp://stream#video=h264#audio=copy#raw=-vf transpose=1,vflip", + ), + ( + Orientation.ROTATE_RIGHT, + "ffmpeg:rtsp://stream#video=h264#audio=copy#rotate=90", + ), + (Orientation.NO_TRANSFORM, "rtsp://stream"), + ], +) +@pytest.mark.parametrize( + "test_fn", + [ + _test_camera_orientation_webrtc, + _test_camera_orientation_get_image, + ], +) +async def test_stream_orientation( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, + orientation: Orientation, + expected_stream_source: str, + test_fn: Callable[ + [HomeAssistant, MockCamera, Orientation, AsyncMock, str], Awaitable[None] + ], +) -> None: + """Test WebRTC provider applies correct orientation filters.""" + camera = init_test_integration + + await test_fn( + hass, + camera, + orientation, + rest_client, + expected_stream_source, + ) + + +@pytest.mark.usefixtures("init_integration", "ws_client") +@pytest.mark.parametrize( + "test_fn", + [ + _test_camera_orientation_webrtc, + _test_camera_orientation_get_image, + ], +) +async def test_stream_orientation_stream_source_starts_ffmpeg( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, + test_fn: Callable[ + [HomeAssistant, MockCamera, Orientation, AsyncMock, str], Awaitable[None] + ], +) -> None: + """Test WebRTC provider applies correct orientation filters when a stream source already starts with ffmpeg.""" + camera = init_test_integration + camera.set_stream_source("ffmpeg:rtsp://test.stream") + + await test_fn( + hass, + camera, + Orientation.ROTATE_LEFT, + rest_client, + "ffmpeg:rtsp://test.stream#video=h264#audio=copy#rotate=-90", + ) + + +@pytest.mark.usefixtures("init_integration", "ws_client") +@pytest.mark.parametrize( + "test_fn", + [ + _test_camera_orientation_webrtc, + _test_camera_orientation_get_image, + ], +) +async def test_stream_orientation_with_generic_camera( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, + test_fn: Callable[ + [HomeAssistant, MockCamera, Orientation, AsyncMock, str], Awaitable[None] + ], +) -> None: + """Test WebRTC provider with orientation and generic camera platform.""" + camera = init_test_integration + camera.set_stream_source("https://test.stream/video.m3u8") + + # Test WebRTC offer handling with generic platform + with patch.object(camera.platform.platform_data, "platform_name", "generic"): + await test_fn( + hass, + camera, + Orientation.FLIP, + rest_client, + "ffmpeg:https://test.stream/video.m3u8#video=h264#audio=copy#raw=-vf vflip", + ) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 48cb1806bf1..02f9e1b48bd 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -814,51 +814,6 @@ async def test_calendar_yaml_update( assert not hass.states.get(TEST_YAML_ENTITY) -async def test_update_will_reload( - hass: HomeAssistant, - component_setup: ComponentSetup, - mock_calendars_list: ApiResult, - test_api_calendar: dict[str, Any], - mock_events_list: ApiResult, - config_entry: MockConfigEntry, -) -> None: - """Test updating config entry options will trigger a reload.""" - mock_calendars_list({"items": [test_api_calendar]}) - mock_events_list({}) - await component_setup() - assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.options == {} # read_write is default - - with patch( - "homeassistant.config_entries.ConfigEntries.async_reload", - return_value=None, - ) as mock_reload: - # No-op does not reload - hass.config_entries.async_update_entry( - config_entry, options={CONF_CALENDAR_ACCESS: "read_write"} - ) - await hass.async_block_till_done() - mock_reload.assert_not_called() - - # Data change does not trigger reload - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - "example": "field", - }, - ) - await hass.async_block_till_done() - mock_reload.assert_not_called() - - # Reload when options changed - hass.config_entries.async_update_entry( - config_entry, options={CONF_CALENDAR_ACCESS: "read_only"} - ) - await hass.async_block_till_done() - mock_reload.assert_called_once() - - @pytest.mark.parametrize("config_entry_unique_id", [None]) async def test_assign_unique_id( hass: HomeAssistant, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index cf9c8047049..809f57e4309 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -155,7 +155,7 @@ async def test_camera_stream(hass: HomeAssistant) -> None: ) trt = trait.CameraStreamTrait( - hass, State("camera.bla", camera.STATE_IDLE, {}), BASIC_CONFIG + hass, State("camera.bla", camera.CameraState.IDLE, {}), BASIC_CONFIG ) assert trt.sync_attributes() == { diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 891eb0e1cbe..55791e385f8 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -154,6 +154,7 @@ 987, ), dict({ + 'max_retries': 20, 'timeout': dict({ 'ceil_threshold': 5, 'connect': None, @@ -226,6 +227,7 @@ 987, ), dict({ + 'max_retries': 20, 'timeout': dict({ 'ceil_threshold': 5, 'connect': None, diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index b19482957b2..6c5d70139e2 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -11,6 +11,10 @@ from homeassistant.components.google_generative_ai_conversation.const import ( DEFAULT_CONVERSATION_NAME, DEFAULT_STT_NAME, DEFAULT_TTS_NAME, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CONVERSATION_OPTIONS, + RECOMMENDED_STT_OPTIONS, + RECOMMENDED_TTS_OPTIONS, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API @@ -34,28 +38,28 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: minor_version=3, subentries_data=[ { - "data": {}, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "subentry_type": "conversation", "title": DEFAULT_CONVERSATION_NAME, "subentry_id": "ulid-conversation", "unique_id": None, }, { - "data": {}, + "data": RECOMMENDED_STT_OPTIONS, "subentry_type": "stt", "title": DEFAULT_STT_NAME, "subentry_id": "ulid-stt", "unique_id": None, }, { - "data": {}, + "data": RECOMMENDED_TTS_OPTIONS, "subentry_type": "tts", "title": DEFAULT_TTS_NAME, "subentry_id": "ulid-tts", "unique_id": None, }, { - "data": {}, + "data": RECOMMENDED_AI_TASK_OPTIONS, "subentry_type": "ai_task_data", "title": DEFAULT_AI_TASK_NAME, "subentry_id": "ulid-ai-task", @@ -143,3 +147,12 @@ def mock_chat_create() -> Generator[AsyncMock]: def mock_send_message_stream(mock_chat_create) -> Generator[AsyncMock]: """Mock stream response.""" return mock_chat_create.return_value.send_message_stream + + +@pytest.fixture +def mock_generate_content() -> Generator[AsyncMock]: + """Mock generate_content response.""" + with patch( + "google.genai.models.AsyncModels.generate_content", + ) as mock: + yield mock diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr new file mode 100644 index 00000000000..c8b1dd93be4 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_function_call + list([ + Content( + parts=[ + Part( + text='Please call the test function' + ), + ], + role='user' + ), + Content( + parts=[ + Part( + text='Hi there!', + thought_signature=b'_thought_signature_2' + ), + Part( + text='The user asked me to call a function', + thought=True, + thought_signature=b'_thought_signature_1' + ), + Part( + function_call=FunctionCall( + args={ + 'param1': [ + 'test_value', + "param1's value", + ], + 'param2': 2.7 + }, + name='test_tool' + ), + thought_signature=b'_thought_signature_3' + ), + ], + role='model' + ), + Content( + parts=[ + Part( + function_response=FunctionResponse( + name='test_tool', + response={ + 'result': 'Test response' + } + ) + ), + ], + role='user' + ), + Content( + parts=[ + Part( + text="I've called the ", + thought_signature=b'_thought_signature_4' + ), + Part( + text='test function with the provided parameters.', + thought_signature=b'_thought_signature_5' + ), + ], + role='model' + ), + ]) +# --- diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index bceb12a9256..d559a7d907e 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'subentries': dict({ 'ulid-ai-task': dict({ 'data': dict({ + 'recommended': True, }), 'subentry_id': 'ulid-ai-task', 'subentry_type': 'ai_task_data', @@ -36,6 +37,8 @@ }), 'ulid-stt': dict({ 'data': dict({ + 'prompt': 'Transcribe the attached audio', + 'recommended': True, }), 'subentry_id': 'ulid-stt', 'subentry_type': 'stt', @@ -44,6 +47,7 @@ }), 'ulid-tts': dict({ 'data': dict({ + 'recommended': True, }), 'subentry_id': 'ulid-tts', 'subentry_type': 'tts', diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py index 6326bd94ad9..25799ef4bc1 100644 --- a/tests/components/google_generative_ai_conversation/test_ai_task.py +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -1,13 +1,17 @@ """Test AI Task platform of Google Generative AI Conversation integration.""" from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +from freezegun import freeze_time from google.genai.types import File, FileState, GenerateContentResponse import pytest import voluptuous as vol from homeassistant.components import ai_task, media_source +from homeassistant.components.google_generative_ai_conversation.const import ( + RECOMMENDED_IMAGE_MODEL, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -216,3 +220,70 @@ async def test_generate_data( instructions="Test prompt", structure=vol.Schema({vol.Required("bla"): str}), ) + + +@pytest.mark.usefixtures("mock_init_component") +@freeze_time("2025-06-14 22:59:00") +async def test_generate_image( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_generate_content: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task image generation.""" + mock_image_data = b"fake_image_data" + mock_generate_content.return_value = Mock( + text="Here is your generated image", + prompt_feedback=None, + candidates=[ + Mock( + content=Mock( + parts=[ + Mock( + text="Here is your generated image", + inline_data=None, + thought=False, + ), + Mock( + inline_data=Mock( + data=mock_image_data, mime_type="image/png" + ), + text=None, + thought=False, + ), + ] + ) + ) + ], + ) + + with patch.object( + media_source.local_source.LocalSource, + "async_upload_media", + return_value="media-source://ai_task/image/2025-06-14_225900_test_task.png", + ) as mock_upload_media: + result = await ai_task.async_generate_image( + hass, + task_name="Test Task", + entity_id="ai_task.google_ai_task", + instructions="Generate a test image", + ) + + assert result["height"] is None + assert result["width"] is None + assert result["revised_prompt"] == "Generate a test image" + assert result["mime_type"] == "image/png" + assert result["model"] == RECOMMENDED_IMAGE_MODEL.partition("/")[-1] + + mock_upload_media.assert_called_once() + image_data = mock_upload_media.call_args[0][1] + assert image_data.file.getvalue() == mock_image_data + assert image_data.content_type == "image/png" + assert image_data.filename == "2025-06-14_225900_test_task.png" + + # Verify that generate_content was called with correct parameters + assert mock_generate_content.called + call_args = mock_generate_content.call_args + assert call_args.kwargs["model"] == RECOMMENDED_IMAGE_MODEL + assert call_args.kwargs["contents"] == ["Generate a test image"] + assert call_args.kwargs["config"].response_modalities == ["TEXT", "IMAGE"] diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index ab8c10e933b..9085e90f634 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun import freeze_time from google.genai.types import GenerateContentResponse import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.conversation import UserContent @@ -80,6 +81,7 @@ async def test_function_call( mock_config_entry_with_assist: MockConfigEntry, mock_chat_log: MockChatLog, # noqa: F811 mock_send_message_stream: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test function calling.""" agent_id = "conversation.google_ai_conversation" @@ -93,9 +95,15 @@ async def test_function_call( { "content": { "parts": [ + { + "text": "The user asked me to call a function", + "thought": True, + "thought_signature": b"_thought_signature_1", + }, { "text": "Hi there!", - } + "thought_signature": b"_thought_signature_2", + }, ], "role": "model", } @@ -118,6 +126,7 @@ async def test_function_call( "param2": 2.7, }, }, + "thought_signature": b"_thought_signature_3", } ], "role": "model", @@ -136,6 +145,7 @@ async def test_function_call( "parts": [ { "text": "I've called the ", + "thought_signature": b"_thought_signature_4", } ], "role": "model", @@ -150,6 +160,25 @@ async def test_function_call( "parts": [ { "text": "test function with the provided parameters.", + "thought_signature": b"_thought_signature_5", + } + ], + "role": "model", + }, + "finish_reason": "STOP", + } + ], + ), + ], + # Follow-up response + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": "You are welcome!", } ], "role": "model", @@ -205,6 +234,22 @@ async def test_function_call( "video_metadata": None, } + # Test history conversion for multi-turn conversation + with patch( + "google.genai.chats.AsyncChats.create", return_value=AsyncMock() + ) as mock_create: + mock_create.return_value.send_message_stream = mock_send_message_stream + await conversation.async_converse( + hass, + "Thank you!", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", + ) + + assert mock_create.call_args[1].get("history") == snapshot + @pytest.mark.usefixtures("mock_init_component") @pytest.mark.usefixtures("mock_ulid_tools") diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index fbd52dc9245..8098eed7f15 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -576,6 +576,8 @@ async def test_migration_from_v1( @pytest.mark.parametrize( ( "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", "merged_config_entry_disabled_by", "conversation_subentry_data", "main_config_entry", @@ -583,6 +585,8 @@ async def test_migration_from_v1( [ ( [ConfigEntryDisabler.USER, None], + [DeviceEntryDisabler.CONFIG_ENTRY, None], + [RegistryEntryDisabler.CONFIG_ENTRY, None], None, [ { @@ -602,18 +606,20 @@ async def test_migration_from_v1( ), ( [None, ConfigEntryDisabler.USER], + [None, DeviceEntryDisabler.CONFIG_ENTRY], + [None, RegistryEntryDisabler.CONFIG_ENTRY], None, [ { "conversation_entity_id": "conversation.google_generative_ai_conversation", - "device_disabled_by": DeviceEntryDisabler.USER, - "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device_disabled_by": None, + "entity_disabled_by": None, "device": 0, }, { "conversation_entity_id": "conversation.google_generative_ai_conversation_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, "device": 1, }, ], @@ -621,6 +627,8 @@ async def test_migration_from_v1( ), ( [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + [DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY], + [RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY], ConfigEntryDisabler.USER, [ { @@ -631,8 +639,8 @@ async def test_migration_from_v1( }, { "conversation_entity_id": "conversation.google_generative_ai_conversation_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, "device": 1, }, ], @@ -645,6 +653,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, @@ -684,7 +694,7 @@ async def test_migration_from_v1_disabled( manufacturer="Google", model="Generative AI", entry_type=dr.DeviceEntryType.SERVICE, - disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + disabled_by=device_disabled_by[0], ) entity_registry.async_get_or_create( "conversation", @@ -693,7 +703,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry, device_id=device_1.id, suggested_object_id="google_generative_ai_conversation", - disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + disabled_by=entity_disabled_by[0], ) device_2 = device_registry.async_get_or_create( @@ -703,6 +713,7 @@ async def test_migration_from_v1_disabled( manufacturer="Google", model="Generative AI", entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=device_disabled_by[1], ) entity_registry.async_get_or_create( "conversation", @@ -711,6 +722,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry_2, device_id=device_2.id, suggested_object_id="google_generative_ai_conversation_2", + disabled_by=entity_disabled_by[1], ) devices = [device_1, device_2] diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 87fc4fe8a76..c55a2a2795d 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -208,6 +208,7 @@ async def test_tts_service_speak( threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, ), ], + thinking_config=None, ), ) @@ -276,5 +277,6 @@ async def test_tts_service_speak_error( threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, ), ], + thinking_config=None, ), ) diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index ce059e4fce5..9a3c3083591 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -6,9 +6,9 @@ from google_photos_library_api.exceptions import GooglePhotosApiError import pytest from homeassistant.components.google_photos.const import DOMAIN, UPLOAD_SCOPE +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import ( URI_SCHEME, - BrowseError, async_browse_media, async_resolve_media, ) diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 88adcbf6587..cb8cd15ca5d 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -117,7 +117,7 @@ def fake_delay(hass: HomeAssistant, ha_delay: int) -> None: def test_name(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the name.""" - api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) + _api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] sensor.platform = MockEntityPlatform(hass) @@ -129,7 +129,7 @@ def test_unit_of_measurement( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test the unit of measurement.""" - api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + _api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] assert value["units"] == sensor.unit_of_measurement @@ -137,7 +137,7 @@ def test_unit_of_measurement( def test_icon(requests_mock: requests_mock.Mocker) -> None: """Test the icon.""" - api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) + _api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] assert value["icon"] == sensor.icon @@ -145,7 +145,7 @@ def test_icon(requests_mock: requests_mock.Mocker) -> None: def test_state(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the initial state.""" - api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + _api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name, value in sensor_dict.items(): @@ -166,7 +166,7 @@ def test_update_when_value_is_none( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test state gets updated to unknown when sensor returns no data.""" - api, sensor_dict = setup_api(hass, None, requests_mock) + _api, sensor_dict = setup_api(hass, None, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] fake_delay(hass, 2) @@ -178,7 +178,7 @@ def test_update_when_value_changed( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test state gets updated when sensor returns a new status.""" - api, sensor_dict = setup_api(hass, MOCK_DATA_NEXT, requests_mock) + _api, sensor_dict = setup_api(hass, MOCK_DATA_NEXT, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name, value in sensor_dict.items(): @@ -203,7 +203,7 @@ def test_when_api_data_missing( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test state logs an error when data is missing.""" - api, sensor_dict = setup_api(hass, MOCK_DATA_MISSING, requests_mock) + _api, sensor_dict = setup_api(hass, MOCK_DATA_MISSING, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for value in sensor_dict.values(): @@ -232,6 +232,6 @@ def update_side_effect( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Mock representation of update function.""" - api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + api, _sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) api.data = None api.available = False diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index e6e336a70f2..32ef2408c01 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -1,6 +1,7 @@ """Test Govee light local config flow.""" from errno import EADDRINUSE +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch from govee_local_api import GoveeDevice @@ -61,17 +62,22 @@ async def test_creating_entry_has_with_devices( mock_govee_api.devices = _get_devices(mock_govee_api) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + # Mock duplicated IPs to ensure that only one GoveeController is started + with patch( + "homeassistant.components.network.async_get_enabled_source_ips", + return_value=[IPv4Address("192.168.1.2"), IPv4Address("192.168.1.2")], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() mock_govee_api.start.assert_awaited_once() mock_setup_entry.assert_awaited_once() diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index e17ea90047b..746511ed0be 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -3,25 +3,45 @@ from copy import deepcopy from unittest.mock import patch +import growattServer +import pytest +import requests + from homeassistant import config_entries from homeassistant.components.growatt_server.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, ) -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -FIXTURE_USER_INPUT = { +FIXTURE_USER_INPUT_PASSWORD = { CONF_USERNAME: "username", CONF_PASSWORD: "password", CONF_URL: DEFAULT_URL, } +FIXTURE_USER_INPUT_TOKEN = { + CONF_TOKEN: "test_api_token_12345", +} + GROWATT_PLANT_LIST_RESPONSE = { "data": [ { @@ -44,67 +64,222 @@ GROWATT_PLANT_LIST_RESPONSE = { }, "success": True, } + GROWATT_LOGIN_RESPONSE = {"user": {"id": 123456}, "userLevel": 1, "success": True} +# API token responses +GROWATT_V1_PLANT_LIST_RESPONSE = { + "plants": [ + { + "plant_id": 123456, + "name": "Test Plant V1", + "plant_uid": "test_uid_123", + } + ] +} -async def test_show_authenticate_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" +GROWATT_V1_MULTIPLE_PLANTS_RESPONSE = { + "plants": [ + { + "plant_id": 123456, + "name": "Test Plant 1", + "plant_uid": "test_uid_123", + }, + { + "plant_id": 789012, + "name": "Test Plant 2", + "plant_uid": "test_uid_789", + }, + ] +} + + +# Menu navigation tests +async def test_show_auth_menu(hass: HomeAssistant) -> None: + """Test that the authentication menu is displayed.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" + assert result["menu_options"] == ["password_auth", "token_auth"] -async def test_incorrect_login(hass: HomeAssistant) -> None: - """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" +# Parametrized authentication form tests +@pytest.mark.parametrize( + ("auth_type", "expected_fields"), + [ + ("password_auth", [CONF_USERNAME, CONF_PASSWORD, CONF_URL]), + ("token_auth", [CONF_TOKEN]), + ], +) +async def test_auth_form_display( + hass: HomeAssistant, auth_type: str, expected_fields: list[str] +) -> None: + """Test that authentication forms are displayed correctly.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + # Select authentication method + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": auth_type} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == auth_type + for field in expected_fields: + assert field in result["data_schema"].schema + + +async def test_password_auth_incorrect_login(hass: HomeAssistant) -> None: + """Test password authentication with incorrect credentials, then recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + with patch( "growattServer.GrowattApi.login", return_value={"msg": LOGIN_INVALID_AUTH_CODE, "success": False}, ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} + assert result["step_id"] == "password_auth" + assert result["errors"] == {"base": ERROR_INVALID_AUTH} + + # Test recovery - retry with correct credentials + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD -async def test_no_plants_on_account(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" +async def test_password_auth_no_plants(hass: HomeAssistant) -> None: + """Test password authentication with no plants.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() - plant_list = deepcopy(GROWATT_PLANT_LIST_RESPONSE) - plant_list["data"] = [] + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) with ( patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), - patch("growattServer.GrowattApi.plant_list", return_value=plant_list), + patch("growattServer.GrowattApi.plant_list", return_value={"data": []}), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_plants" + assert result["reason"] == ABORT_NO_PLANTS -async def test_multiple_plant_ids(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" +async def test_token_auth_no_plants(hass: HomeAssistant) -> None: + """Test token authentication with no plants.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() + + # Select token authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with patch("growattServer.OpenApiV1.plant_list", return_value={"plants": []}): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == ABORT_NO_PLANTS + + +async def test_password_auth_single_plant(hass: HomeAssistant) -> None: + """Test password authentication with single plant.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD + assert result["data"][CONF_NAME] == "Plant name" + assert result["result"].unique_id == "123456" + + +async def test_password_auth_multiple_plants(hass: HomeAssistant) -> None: + """Test password authentication with multiple plants.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + plant_list = deepcopy(GROWATT_PLANT_LIST_RESPONSE) - plant_list["data"].append(plant_list["data"][0]) + plant_list["data"].append( + { + "plantMoneyText": "300.0 (€)", + "plantName": "Plant name 2", + "plantId": "789012", + "isHaveStorage": "true", + "todayEnergy": "1.5 kWh", + "totalEnergy": "1.8 MWh", + "currentPower": "420.0 W", + } + ) with ( patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), @@ -115,11 +290,14 @@ async def test_multiple_plant_ids(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) + + # Should show plant selection form assert result["type"] is FlowResultType.FORM assert result["step_id"] == "plant" + # Select first plant user_input = {CONF_PLANT_ID: "123456"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input @@ -127,18 +305,305 @@ async def test_multiple_plant_ids(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD + assert result["result"].unique_id == "123456" -async def test_one_plant_on_account(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" +# Token authentication tests + + +async def test_token_auth_api_error(hass: HomeAssistant) -> None: + """Test token authentication with API error, then recovery.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + # Any GrowattV1ApiError during token verification should result in invalid_auth + error = growattServer.GrowattV1ApiError("API error") + error.error_code = 100 + + with patch("growattServer.OpenApiV1.plant_list", side_effect=error): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "token_auth" + assert result["errors"] == {"base": ERROR_INVALID_AUTH} + + # Test recovery - retry with valid token + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + + +async def test_token_auth_connection_error(hass: HomeAssistant) -> None: + """Test token authentication with network error, then recovery.""" + + 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"], {"next_step_id": "token_auth"} + ) + + with patch( + "growattServer.OpenApiV1.plant_list", + side_effect=requests.exceptions.ConnectionError("Network error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "token_auth" + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + # Test recovery - retry when network is available + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + + +async def test_token_auth_invalid_response(hass: HomeAssistant) -> None: + """Test token authentication with invalid response format, then recovery.""" + + 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"], {"next_step_id": "token_auth"} + ) + + with patch( + "growattServer.OpenApiV1.plant_list", + return_value=None, # Invalid response format + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "token_auth" + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + # Test recovery - retry with valid response + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + + +async def test_token_auth_single_plant(hass: HomeAssistant) -> None: + """Test token authentication with single plant.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select token authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + assert result["data"][CONF_NAME] == "Test Plant V1" + assert result["result"].unique_id == "123456" + + +async def test_token_auth_multiple_plants(hass: HomeAssistant) -> None: + """Test token authentication with multiple plants.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select token authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_MULTIPLE_PLANTS_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + # Should show plant selection form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "plant" + + # Select second plant + user_input = {CONF_PLANT_ID: "789012"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "789012" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + assert result["data"][CONF_NAME] == "Test Plant 2" + assert result["result"].unique_id == "789012" + + +async def test_password_auth_existing_plant_configured(hass: HomeAssistant) -> None: + """Test password authentication with existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="123456") + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_token_auth_existing_plant_configured(hass: HomeAssistant) -> None: + """Test token authentication with existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="123456") + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select token authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_password_auth_connection_error(hass: HomeAssistant) -> None: + """Test password authentication with connection error, then recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with patch( + "growattServer.GrowattApi.login", + side_effect=requests.exceptions.ConnectionError("Connection failed"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "password_auth" + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + # Test recovery - retry when connection is available with ( patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), patch( @@ -151,34 +616,109 @@ async def test_one_plant_on_account(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD -async def test_existing_plant_configured(hass: HomeAssistant) -> None: - """Test entering an existing plant_id.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id="123456") - entry.add_to_hass(hass) +async def test_password_auth_invalid_response(hass: HomeAssistant) -> None: + """Test password authentication with invalid response format, then recovery.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with patch( + "growattServer.GrowattApi.login", + side_effect=ValueError("Invalid JSON response"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "password_auth" + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + # Test recovery - retry with valid response with ( patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), patch( "growattServer.GrowattApi.plant_list", return_value=GROWATT_PLANT_LIST_RESPONSE, ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD + + +async def test_password_auth_plant_list_error(hass: HomeAssistant) -> None: + """Test password authentication with plant list connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + side_effect=requests.exceptions.ConnectionError("Connection failed"), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == ERROR_CANNOT_CONNECT + + +async def test_password_auth_plant_list_invalid_format(hass: HomeAssistant) -> None: + """Test password authentication with invalid plant list format.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value={"invalid": "format"}, # Missing "data" key + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == ERROR_CANNOT_CONNECT diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png deleted file mode 100644 index 5bb8c9d9f09..00000000000 Binary files a/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png and /dev/null differ diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png deleted file mode 100644 index 8e9b046ee05..00000000000 Binary files a/tests/components/habitica/__snapshots__/test_image/test_image_platform.png and /dev/null differ diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index e66186860c7..7e2017d1683 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -388,6 +388,18 @@ "count": 20 } }, + "boss": { + "name": "boss name", + "hp": 500, + "rage": { + "title": "rage skill name", + "description": "description", + "value": 50, + "effect": "skill effect" + }, + "str": 1, + "def": 1 + }, "drop": { "items": [ { diff --git a/tests/components/habitica/fixtures/party.json b/tests/components/habitica/fixtures/party.json index 18e7936ca85..4d011ccef73 100644 --- a/tests/components/habitica/fixtures/party.json +++ b/tests/components/habitica/fixtures/party.json @@ -9,7 +9,9 @@ "progress": { "collect": { "soapBars": 10 - } + }, + "hp": 50, + "rage": 3.14 }, "key": "atom1", "active": true, diff --git a/tests/components/habitica/fixtures/party_members_2.json b/tests/components/habitica/fixtures/party_members_2.json new file mode 100644 index 00000000000..249a6d6bc87 --- /dev/null +++ b/tests/components/habitica/fixtures/party_members_2.json @@ -0,0 +1,238 @@ +{ + "success": true, + "data": [ + { + "_id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "auth": { + "local": { + "username": "test-username" + }, + "timestamps": { + "created": "2024-10-19T18:43:39.782Z", + "loggedin": "2024-10-31T16:13:35.048Z", + "updated": "2024-10-31T16:15:56.552Z" + } + }, + "achievements": { + "ultimateGearSets": { + "healer": false, + "wizard": false, + "rogue": false, + "warrior": false + }, + "streak": 0, + "challenges": [], + "perfect": 1, + "quests": {}, + "purchasedEquipment": true, + "completedTask": true, + "partyUp": true + }, + "backer": {}, + "contributor": {}, + "flags": { + "verifiedUsername": true, + "classSelected": true + }, + "items": { + "gear": { + "owned": { + "headAccessory_special_blackHeadband": true, + "headAccessory_special_blueHeadband": true, + "headAccessory_special_greenHeadband": true, + "headAccessory_special_pinkHeadband": true, + "headAccessory_special_redHeadband": true, + "headAccessory_special_whiteHeadband": true, + "headAccessory_special_yellowHeadband": true, + "eyewear_special_blackTopFrame": true, + "eyewear_special_blueTopFrame": true, + "eyewear_special_greenTopFrame": true, + "eyewear_special_pinkTopFrame": true, + "eyewear_special_redTopFrame": true, + "eyewear_special_whiteTopFrame": true, + "eyewear_special_yellowTopFrame": true, + "eyewear_special_blackHalfMoon": true, + "eyewear_special_blueHalfMoon": true, + "eyewear_special_greenHalfMoon": true, + "eyewear_special_pinkHalfMoon": true, + "eyewear_special_redHalfMoon": true, + "eyewear_special_whiteHalfMoon": true, + "eyewear_special_yellowHalfMoon": true, + "armor_special_bardRobes": true, + "weapon_special_fall2024Warrior": true, + "shield_special_fall2024Warrior": true, + "head_special_fall2024Warrior": true, + "armor_special_fall2024Warrior": true, + "back_mystery_201402": true, + "body_mystery_202003": true, + "head_special_bardHat": true, + "weapon_wizard_0": true + }, + "equipped": { + "weapon": "weapon_special_fall2024Warrior", + "armor": "armor_special_fall2024Warrior", + "head": "head_special_fall2024Warrior", + "shield": "shield_special_fall2024Warrior", + "back": "back_mystery_201402", + "headAccessory": "headAccessory_special_pinkHeadband", + "eyewear": "eyewear_special_pinkHalfMoon", + "body": "body_mystery_202003" + }, + "costume": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + } + }, + "special": { + "snowball": 99, + "spookySparkles": 99, + "shinySeed": 99, + "seafoam": 99, + "valentine": 0, + "valentineReceived": [], + "nye": 0, + "nyeReceived": [], + "greeting": 0, + "greetingReceived": [], + "thankyou": 0, + "thankyouReceived": [], + "birthday": 0, + "birthdayReceived": [], + "congrats": 0, + "congratsReceived": [], + "getwell": 0, + "getwellReceived": [], + "goodluck": 0, + "goodluckReceived": [] + }, + "pets": { + "Rat-Shade": 1, + "Gryphatrice-Jubilant": 1 + }, + "currentPet": "Gryphatrice-Jubilant", + "eggs": { + "Cactus": 1, + "Fox": 2, + "Wolf": 1 + }, + "hatchingPotions": { + "CottonCandyBlue": 1, + "RoyalPurple": 1 + }, + "food": { + "Meat": 2, + "Chocolate": 1, + "CottonCandyPink": 1, + "Candy_Zombie": 1 + }, + "mounts": { + "Velociraptor-Base": true, + "Gryphon-Gryphatrice": true + }, + "currentMount": "Gryphon-Gryphatrice", + "quests": { + "dustbunnies": 1, + "vice1": 1, + "atom1": 1, + "moonstone1": 1, + "goldenknight1": 1, + "basilist": 1 + }, + "lastDrop": { + "date": "2024-10-31T16:13:34.952Z", + "count": 0 + } + }, + "party": { + "quest": { + "progress": { + "up": 0, + "down": 0, + "collectedItems": 0, + "collect": {} + }, + "RSVPNeeded": false, + "key": "dustbunnies" + }, + "order": "level", + "orderAscending": "ascending", + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "preferences": { + "size": "slim", + "hair": { + "color": "red", + "base": 3, + "bangs": 1, + "beard": 0, + "mustache": 0, + "flower": 1 + }, + "skin": "915533", + "shirt": "blue", + "chair": "handleless_pink", + "costume": false, + "sleep": false, + "disableClasses": false, + "tasks": { + "groupByChallenge": false, + "confirmScoreNotes": false, + "mirrorGroupTasks": [], + "activeFilter": { + "habit": "all", + "daily": "all", + "todo": "remaining", + "reward": "all" + } + }, + "background": "violet" + }, + "profile": { + "name": "test-user" + }, + "stats": { + "hp": 50, + "mp": 150.8, + "exp": 127, + "gp": 19.08650199252128, + "lvl": 99, + "class": "wizard", + "points": 0, + "str": 0, + "con": 0, + "int": 0, + "per": 0, + "buffs": { + "str": 50, + "int": 50, + "per": 50, + "con": 50, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "training": { + "int": 0, + "per": 0, + "str": 0, + "con": 0 + }, + "toNextLevel": 3580, + "maxHealth": 50, + "maxMP": 228 + }, + "inbox": { + "optOut": false + }, + "loginIncentives": 6, + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303" + } + ], + "notifications": [], + "userV": 96, + "appVersion": "5.29.0" +} diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index 64dbc160a1b..4a06b92035e 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -71,7 +71,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Quest status', 'platform': 'habitica', @@ -86,7 +86,6 @@ # name: test_binary_sensors[binary_sensor.test_user_s_party_quest_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'running', 'friendly_name': "test-user's Party Quest status", }), 'context': , diff --git a/tests/components/habitica/snapshots/test_notify.ambr b/tests/components/habitica/snapshots/test_notify.ambr new file mode 100644 index 00000000000..248f6e292d6 --- /dev/null +++ b/tests/components/habitica/snapshots/test_notify.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_notify_platform[notify.test_user_party_chat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.test_user_party_chat', + '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': 'Party chat', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_party_chat', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.test_user_party_chat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test-user Party chat', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.test_user_party_chat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_notify_platform[notify.test_user_private_message_test_partymember_displayname-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.test_user_private_message_test_partymember_displayname', + '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': 'Private message: test-partymember-displayname', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_ffce870c-3ff3-4fa4-bad1-87612e52b8e7_private_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.test_user_private_message_test_partymember_displayname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test-user Private message: test-partymember-displayname', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.test_user_private_message_test_partymember_displayname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 89d6936f111..07ce6488914 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -544,6 +544,55 @@ 'state': '72', }) # --- +# name: test_sensors[sensor.test_user_last_check_in-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': None, + 'entity_id': 'sensor.test_user_last_check_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last check-in', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_last_checkin', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_last_check_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test-user Last check-in', + }), + 'context': , + 'entity_id': 'sensor.test_user_last_check_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-02T03:14:33+00:00', + }) +# --- # name: test_sensors[sensor.test_user_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1114,7 +1163,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '500.0', }) # --- # name: test_sensors[sensor.test_user_s_party_boss_health_remaining-entry] @@ -1167,7 +1216,115 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_rage-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': None, + 'entity_id': 'sensor.test_user_s_party_boss_rage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boss rage', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss_rage', + 'unit_of_measurement': 'rage', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_rage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSItMiAtMiAxOCAyMCI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBmaWxsLXJ1bGU9Im5vbnplcm8iPjxnPjxnPjxnPjxwYXRoIGZpbGw9IiMyNENDOEYiIGQ9Ik0wIDZMNS44MzMgMCAxMS42NjcgNiA1LjgzMyAxNnoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iIzI0Q0M4RiIgZD0iTTAgNkw1LjgzMyAwIDExLjY2NyA2IDUuODMzIDE2eiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJNMTAuMTUgNi4yTDUuODMzIDUuMiA1LjgzMyAxLjh6IiBvcGFjaXR5PSIuMjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iI0ZGRiIgZD0iTTUuODMzIDUuMkwxLjUxNyA2LjIgNS44MzMgMS44eiIgb3BhY2l0eT0iLjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iIzVBRTRCMiIgZD0iTTUuODMzIDUuMkw1LjgzMyAxMy42IDEuNTE3IDYuMnoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iIzFCOTk2QiIgZD0iTTEwLjE1IDYuMkw1LjgzMyAxMy42IDUuODMzIDUuMnoiIG9wYWNpdHk9Ii4zNSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRjQ3ODI1IiBkPSJNMTEuNjY3IDZMNS44MzMgMCAwIDYgMS4xNjcgOCAwIDEwIDUuODMzIDE2IDExLjY2NyAxMCAxMC41IDh6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNTI0IC0yMDcwKSB0cmFuc2xhdGUoNDg0IDE2MzIpIHRyYW5zbGF0ZSg0MCA0MzgpIHRyYW5zbGF0ZSguODc1KSI+PC9wYXRoPjxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik0xMC4xNSA2LjJMNS44MzMgNS4yIDUuODMzIDEuOHoiIG9wYWNpdHk9Ii4yNSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJNNS44MzMgNS4yTDEuNTE3IDYuMiA1LjgzMyAxLjh6IiBvcGFjaXR5PSIuNSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJNNS44MzMgNS4yTDUuODMzIDEzLjYgNC42OTkgMTEuNjUzIDEuNTE3IDYuMnpNNS44MzMgMTAuOEw1LjgzMyAyLjQgNi45NjggNC4zNDcgMTAuMTUgOS44eiIgb3BhY2l0eT0iLjI1IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNTI0IC0yMDcwKSB0cmFuc2xhdGUoNDg0IDE2MzIpIHRyYW5zbGF0ZSg0MCA0MzgpIHRyYW5zbGF0ZSguODc1KSI+PC9wYXRoPjxwYXRoIGZpbGw9IiNCNDU5MUIiIGQ9Ik0xMC4xNSA2LjJMNS44MzMgMTMuNiA1LjgzMyA1LjJ6TTEuNTE3IDkuOEw1LjgzMyAxMC44IDUuODMzIDE0LjJ6IiBvcGFjaXR5PSIuMzUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iI0ZGRiIgZD0iTTUuODMzIDEwLjhMMTAuMTUgOS44IDUuODMzIDE0LjJ6IiBvcGFjaXR5PSIuNSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJNMS41MTcgOS44TDUuODMzIDIuNCA1LjgzMyAxMC44eiIgb3BhY2l0eT0iLjI1IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNTI0IC0yMDcwKSB0cmFuc2xhdGUoNDg0IDE2MzIpIHRyYW5zbGF0ZSg0MCA0MzgpIHRyYW5zbGF0ZSguODc1KSI+PC9wYXRoPjxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik0zLjA2MyA5LjUzM0wzLjk3MyA4IDMuMDYzIDYuNDY3IDUuODMzIDMuNjY3IDguNjA0IDYuNDY3IDcuNjk0IDggOC42MDQgOS41MzMgNS44MzMgMTIuMzMzeiIgb3BhY2l0eT0iLjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PC9nPjwvZz48L2c+PC9nPjwvZz48L3N2Zz4=', + 'friendly_name': "test-user's Party Boss rage", + 'unit_of_measurement': 'rage', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_boss_rage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.14', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_rage_limit_break-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': None, + 'entity_id': 'sensor.test_user_s_party_boss_rage_limit_break', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boss rage limit break', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss_rage_limit', + 'unit_of_measurement': 'rage', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_rage_limit_break-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'effect': 'skill effect', + 'entity_picture': 'data:image/svg+xml;base64,CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSItMiAtMiAxOCAyMCI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBmaWxsLXJ1bGU9Im5vbnplcm8iPjxnPjxnPjxnPjxwYXRoIGZpbGw9IiMyNENDOEYiIGQ9Ik0wIDZMNS44MzMgMCAxMS42NjcgNiA1LjgzMyAxNnoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iIzI0Q0M4RiIgZD0iTTAgNkw1LjgzMyAwIDExLjY2NyA2IDUuODMzIDE2eiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJNMTAuMTUgNi4yTDUuODMzIDUuMiA1LjgzMyAxLjh6IiBvcGFjaXR5PSIuMjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iI0ZGRiIgZD0iTTUuODMzIDUuMkwxLjUxNyA2LjIgNS44MzMgMS44eiIgb3BhY2l0eT0iLjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iIzVBRTRCMiIgZD0iTTUuODMzIDUuMkw1LjgzMyAxMy42IDEuNTE3IDYuMnoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iIzFCOTk2QiIgZD0iTTEwLjE1IDYuMkw1LjgzMyAxMy42IDUuODMzIDUuMnoiIG9wYWNpdHk9Ii4zNSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRjQ3ODI1IiBkPSJNMTEuNjY3IDZMNS44MzMgMCAwIDYgMS4xNjcgOCAwIDEwIDUuODMzIDE2IDExLjY2NyAxMCAxMC41IDh6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNTI0IC0yMDcwKSB0cmFuc2xhdGUoNDg0IDE2MzIpIHRyYW5zbGF0ZSg0MCA0MzgpIHRyYW5zbGF0ZSguODc1KSI+PC9wYXRoPjxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik0xMC4xNSA2LjJMNS44MzMgNS4yIDUuODMzIDEuOHoiIG9wYWNpdHk9Ii4yNSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJNNS44MzMgNS4yTDEuNTE3IDYuMiA1LjgzMyAxLjh6IiBvcGFjaXR5PSIuNSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJNNS44MzMgNS4yTDUuODMzIDEzLjYgNC42OTkgMTEuNjUzIDEuNTE3IDYuMnpNNS44MzMgMTAuOEw1LjgzMyAyLjQgNi45NjggNC4zNDcgMTAuMTUgOS44eiIgb3BhY2l0eT0iLjI1IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNTI0IC0yMDcwKSB0cmFuc2xhdGUoNDg0IDE2MzIpIHRyYW5zbGF0ZSg0MCA0MzgpIHRyYW5zbGF0ZSguODc1KSI+PC9wYXRoPjxwYXRoIGZpbGw9IiNCNDU5MUIiIGQ9Ik0xMC4xNSA2LjJMNS44MzMgMTMuNiA1LjgzMyA1LjJ6TTEuNTE3IDkuOEw1LjgzMyAxMC44IDUuODMzIDE0LjJ6IiBvcGFjaXR5PSIuMzUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PHBhdGggZmlsbD0iI0ZGRiIgZD0iTTUuODMzIDEwLjhMMTAuMTUgOS44IDUuODMzIDE0LjJ6IiBvcGFjaXR5PSIuNSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUyNCAtMjA3MCkgdHJhbnNsYXRlKDQ4NCAxNjMyKSB0cmFuc2xhdGUoNDAgNDM4KSB0cmFuc2xhdGUoLjg3NSkiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJNMS41MTcgOS44TDUuODMzIDIuNCA1LjgzMyAxMC44eiIgb3BhY2l0eT0iLjI1IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNTI0IC0yMDcwKSB0cmFuc2xhdGUoNDg0IDE2MzIpIHRyYW5zbGF0ZSg0MCA0MzgpIHRyYW5zbGF0ZSguODc1KSI+PC9wYXRoPjxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik0zLjA2MyA5LjUzM0wzLjk3MyA4IDMuMDYzIDYuNDY3IDUuODMzIDMuNjY3IDguNjA0IDYuNDY3IDcuNjk0IDggOC42MDQgOS41MzMgNS44MzMgMTIuMzMzeiIgb3BhY2l0eT0iLjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01MjQgLTIwNzApIHRyYW5zbGF0ZSg0ODQgMTYzMikgdHJhbnNsYXRlKDQwIDQzOCkgdHJhbnNsYXRlKC44NzUpIj48L3BhdGg+PC9nPjwvZz48L2c+PC9nPjwvZz48L3N2Zz4=', + 'friendly_name': "test-user's Party Boss rage limit break", + 'rage_skill': 'rage skill name', + 'unit_of_measurement': 'rage', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_boss_rage_limit_break', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', }) # --- # name: test_sensors[sensor.test_user_s_party_collected_quest_items-entry] @@ -1415,7 +1572,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'boss name', }) # --- # name: test_sensors[sensor.test_user_saddles-entry] diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 63001157695..a393c7a6082 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -87,7 +87,6 @@ async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N result["flow_id"], user_input=MOCK_DATA_LOGIN_STEP, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-user" @@ -208,7 +207,6 @@ async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) - result["flow_id"], user_input=MOCK_DATA_ADVANCED_STEP, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-user" @@ -329,8 +327,6 @@ async def test_flow_reauth( user_input, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" @@ -399,8 +395,6 @@ async def test_flow_reauth_errors( result["flow_id"], user_input ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} @@ -412,8 +406,6 @@ async def test_flow_reauth_errors( user_input=USER_INPUT_REAUTH_API_KEY, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" @@ -446,8 +438,6 @@ async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: USER_INPUT_REAUTH_LOGIN, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unique_id_mismatch" @@ -469,8 +459,6 @@ async def test_flow_reconfigure( USER_INPUT_RECONFIGURE, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" @@ -507,8 +495,6 @@ async def test_flow_reconfigure_errors( USER_INPUT_RECONFIGURE, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} @@ -519,8 +505,6 @@ async def test_flow_reconfigure_errors( user_input=USER_INPUT_RECONFIGURE, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py index b0810d8e76f..d174b016e64 100644 --- a/tests/components/habitica/test_image.py +++ b/tests/components/habitica/test_image.py @@ -12,7 +12,6 @@ from habiticalib import HabiticaGroupsResponse, HabiticaUserResponse import pytest import respx from syrupy.assertion import SnapshotAssertion -from syrupy.extensions.image import PNGImageSnapshotExtension from homeassistant.components.habitica.const import ASSETS_URL, DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -50,12 +49,8 @@ async def test_image_platform( "homeassistant.components.habitica.coordinator.BytesIO", ) as avatar: avatar.side_effect = [ - BytesIO( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdac\xfc\xcf\xc0\xf0\x1f\x00\x05\x05\x02\x00_\xc8\xf1\xd2\x00\x00\x00\x00IEND\xaeB`\x82" - ), - BytesIO( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdacd`\xf8\xff\x1f\x00\x03\x07\x02\x000&\xc7a\x00\x00\x00\x00IEND\xaeB`\x82" - ), + BytesIO(b"\x89PNGTestImage1"), + BytesIO(b"\x89PNGTestImage2"), ] config_entry.add_to_hass(hass) @@ -77,9 +72,7 @@ async def test_image_platform( resp = await client.get(state.attributes["entity_picture"]) assert resp.status == HTTPStatus.OK - assert (await resp.read()) == snapshot( - extension_class=PNGImageSnapshotExtension - ) + assert (await resp.read()) == b"\x89PNGTestImage1" habitica.get_user.return_value = HabiticaUserResponse.from_json( await async_load_fixture(hass, "rogue_fixture.json", DOMAIN) @@ -95,9 +88,7 @@ async def test_image_platform( resp = await client.get(state.attributes["entity_picture"]) assert resp.status == HTTPStatus.OK - assert (await resp.read()) == snapshot( - extension_class=PNGImageSnapshotExtension - ) + assert (await resp.read()) == b"\x89PNGTestImage2" @pytest.mark.usefixtures("habitica") diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 92be6cbe881..469197b54b1 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -139,7 +139,7 @@ async def test_remove_party_and_reload( freezer: FrozenDateTimeFactory, device_registry: dr.DeviceRegistry, ) -> None: - """Test we leave the party and device is removed.""" + """Test we leave the party and device/notifiers are removed.""" group_id = "1e87097c-4c03-4f8c-a475-67cc7da7f409" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -154,6 +154,11 @@ async def test_remove_party_and_reload( is not None ) + assert hass.states.get("notify.test_user_party_chat") + assert hass.states.get( + "notify.test_user_private_message_test_partymember_displayname" + ) + habitica.get_user.return_value = HabiticaUserResponse.from_json( await async_load_fixture(hass, "user_no_party.json", DOMAIN) ) @@ -168,3 +173,9 @@ async def test_remove_party_and_reload( ) is None ) + + assert hass.states.get("notify.test_user_party_chat") is None + assert ( + hass.states.get("notify.test_user_private_message_test_partymember_displayname") + is None + ) diff --git a/tests/components/habitica/test_notify.py b/tests/components/habitica/test_notify.py new file mode 100644 index 00000000000..6f2988a3fcc --- /dev/null +++ b/tests/components/habitica/test_notify.py @@ -0,0 +1,191 @@ +"""Tests for the Habitica notify platform.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, patch +from uuid import UUID + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory, freeze_time +from habiticalib import HabiticaGroupMembersResponse +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.components.notify import ( + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + ERROR_BAD_REQUEST, + ERROR_NOT_AUTHORIZED, + ERROR_NOT_FOUND, + ERROR_TOO_MANY_REQUESTS, +) + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_fixture, + snapshot_platform, +) + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("habitica") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the notify platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "call_method", "call_args"), + [ + ( + "notify.test_user_party_chat", + "send_group_message", + {"group_id": UUID("1e87097c-4c03-4f8c-a475-67cc7da7f409")}, + ), + ( + "notify.test_user_private_message_test_partymember_displayname", + "send_private_message", + {"to_user_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7")}, + ), + ], +) +@freeze_time("2025-08-13T00:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + entity_id: str, + call_method: str, + call_args: dict[str, Any], +) -> None: + """Test send message.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MESSAGE: "Greetings, fellow adventurer", + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "2025-08-13T00:00:00+00:00" + getattr(habitica, call_method).assert_called_once_with( + message="Greetings, fellow adventurer", **call_args + ) + + +@pytest.mark.parametrize( + "exception", + [ + ERROR_BAD_REQUEST, + ERROR_NOT_AUTHORIZED, + ERROR_NOT_FOUND, + ERROR_TOO_MANY_REQUESTS, + ClientError, + ], +) +async def test_send_message_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, +) -> None: + """Test send message exceptions.""" + + habitica.send_group_message.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.test_user_party_chat", + ATTR_MESSAGE: "Greetings, fellow adventurer", + }, + blocking=True, + ) + + +async def test_remove_stale_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test removing stale private message entities.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get( + "notify.test_user_private_message_test_partymember_displayname" + ) + + habitica.get_group_members.return_value = HabiticaGroupMembersResponse.from_json( + await async_load_fixture(hass, "party_members_2.json", DOMAIN) + ) + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("notify.test_user_private_message_test_partymember_displayname") + is None + ) diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 0e2a99ce215..3692361942a 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -28,6 +28,7 @@ from homeassistant.components.habitica.const import ( ATTR_ALIAS, ATTR_CLEAR_DATE, ATTR_CLEAR_REMINDER, + ATTR_COLLAPSE_CHECKLIST, ATTR_CONFIG_ENTRY, ATTR_COST, ATTR_COUNTER_DOWN, @@ -1498,6 +1499,18 @@ async def test_create_habit( }, Task(alias="ALIAS"), ), + ( + { + ATTR_COLLAPSE_CHECKLIST: "collapsed", + }, + Task(collapseChecklist=True), + ), + ( + { + ATTR_COLLAPSE_CHECKLIST: "expanded", + }, + Task(collapseChecklist=False), + ), ], ) @pytest.mark.usefixtures("mock_uuid4") @@ -1596,6 +1609,20 @@ async def test_update_todo( }, Task(type=TaskType.TODO, text="TITLE", alias="ALIAS"), ), + ( + { + ATTR_NAME: "TITLE", + ATTR_COLLAPSE_CHECKLIST: "collapsed", + }, + Task(type=TaskType.TODO, text="TITLE", collapseChecklist=True), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_COLLAPSE_CHECKLIST: "expanded", + }, + Task(type=TaskType.TODO, text="TITLE", collapseChecklist=False), + ), ], ) @pytest.mark.usefixtures("mock_uuid4") diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a71ee370b32..476062ab6af 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -108,6 +108,7 @@ def all_setup_requests( "chassis": "vm", "operating_system": "Debian GNU/Linux 10 (buster)", "kernel": "4.19.0-6-amd64", + "disk_free": 1.6, }, }, }, diff --git a/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json b/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json index 183a38a60db..e13bf364e9a 100644 --- a/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json +++ b/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json @@ -19,7 +19,8 @@ "done": true, "errors": [], "created": "2025-05-14T08:56:22.807078+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_store_addons", @@ -57,7 +58,8 @@ } ], "created": "2025-05-14T08:56:22.844160+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_addon_save", @@ -74,9 +76,11 @@ } ], "created": "2025-05-14T08:56:22.850376+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null } - ] + ], + "extra": null }, { "name": "backup_store_folders", @@ -119,7 +123,8 @@ } ], "created": "2025-05-14T08:56:22.858385+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_folder_save", @@ -136,7 +141,8 @@ } ], "created": "2025-05-14T08:56:22.859973+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_folder_save", @@ -153,10 +159,13 @@ } ], "created": "2025-05-14T08:56:22.860792+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null } - ] + ], + "extra": null } - ] + ], + "extra": null } } diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 3bc397b46f9..0d9b0defe83 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -268,6 +268,7 @@ TEST_JOB_NOT_DONE = supervisor_jobs.Job( errors=[], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) TEST_JOB_DONE = supervisor_jobs.Job( name="backup_manager_partial_backup", @@ -279,6 +280,7 @@ TEST_JOB_DONE = supervisor_jobs.Job( errors=[], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( name="backup_manager_partial_restore", @@ -294,10 +296,12 @@ TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( "Backup was made on supervisor version 2025.02.2.dev3105, " "can't restore on 2025.01.2.dev3105" ), + stage=None, ) ], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index a4ad0a4a004..20473ff4041 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -163,6 +163,31 @@ async def test_unhealthy_issues( assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") +@pytest.mark.usefixtures("all_setup_requests") +@pytest.mark.parametrize("unhealthy_reason", list(UnhealthyReason)) +async def test_unhealthy_reasons( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, + unhealthy_reason: UnhealthyReason, +) -> None: + """Test all unhealthy reasons in client library are properly made into repairs with a translation.""" + mock_resolution_info(supervisor_client, unhealthy=[unhealthy_reason]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list( + msg["result"]["issues"], unhealthy=True, reason=unhealthy_reason.value + ) + + @pytest.mark.usefixtures("all_setup_requests") async def test_unsupported_issues( hass: HomeAssistant, @@ -190,6 +215,34 @@ async def test_unsupported_issues( assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") +@pytest.mark.usefixtures("all_setup_requests") +@pytest.mark.parametrize( + "unsupported_reason", + [r for r in UnsupportedReason if r != UnsupportedReason.PRIVILEGED], +) +async def test_unsupported_reasons( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, + unsupported_reason: UnsupportedReason, +) -> None: + """Test all unsupported reasons in client library are properly made into repairs with a translation.""" + mock_resolution_info(supervisor_client, unsupported=[unsupported_reason]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list( + msg["result"]["issues"], unhealthy=False, reason=unsupported_reason.value + ) + + @pytest.mark.usefixtures("all_setup_requests") async def test_unhealthy_issues_add_remove( hass: HomeAssistant, @@ -897,3 +950,157 @@ async def test_supervisor_issues_disk_lifetime( fixable=False, placeholders=None, ) + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_free_space( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for too little free space remaining.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "free_space", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="system", + type_="free_space", + fixable=False, + placeholders={ + "more_info_free_space": "https://www.home-assistant.io/more-info/free-space", + "free_space": "1.6", + }, + ) + + +async def test_supervisor_issues_free_space_host_info_fail( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for too little free space remaining without host info.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "free_space", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="system", + type_="free_space", + fixable=False, + placeholders={ + "more_info_free_space": "https://www.home-assistant.io/more-info/free-space", + "free_space": "<2", + }, + ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_addon_pwned( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for pwned secret in an addon.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "pwned", + "context": "addon", + "reference": "test", + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="addon", + type_="pwned", + fixable=False, + placeholders={ + "reference": "test", + "addon": "test", + "addon_url": "/hassio/addon/test", + "more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords", + }, + ) diff --git a/tests/components/hassio/test_switch.py b/tests/components/hassio/test_switch.py new file mode 100644 index 00000000000..7963389e8ca --- /dev/null +++ b/tests/components/hassio/test_switch.py @@ -0,0 +1,305 @@ +"""The tests for the hassio switch.""" + +from collections.abc import AsyncGenerator +import os +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.hassio import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> AsyncGenerator[MockConfigEntry]: + """Set up the hassio integration and enable entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + yield config_entry + + +async def enable_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Enable an entity and reload the config entry.""" + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.fixture(autouse=True) +def mock_all( + aioclient_mock: AiohttpClientMocker, + addon_installed: AsyncMock, + store_info: AsyncMock, + addon_changelog: AsyncMock, + addon_stats: AsyncMock, + resolution_info: AsyncMock, +) -> None: + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={ + "result": "ok", + "data": { + "version_latest": "1.0.0", + "version": "1.0.0", + "update_available": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + { + "name": "test-two", + "state": "stopped", + "slug": "test-two", + "installed": True, + "update_available": False, + "icon": True, + "version": "3.1.0", + "version_latest": "3.1.0", + "repository": "core", + "url": "https://github.com", + }, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +@pytest.mark.parametrize( + ("entity_id", "expected", "addon_state"), + [ + ("switch.test", "on", "started"), + ("switch.test_two", "off", "stopped"), + ], +) +async def test_switch_state( + hass: HomeAssistant, + entity_id: str, + expected: str, + addon_state: str, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, + setup_integration: MockConfigEntry, +) -> None: + """Test hassio addon switch state.""" + addon_installed.return_value.state = addon_state + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + await enable_entity(hass, entity_registry, setup_integration, entity_id) + + # Verify that the entity have the expected state. + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +async def test_switch_turn_on( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, + setup_integration: MockConfigEntry, +) -> None: + """Test turning on addon switch.""" + entity_id = "switch.test_two" + addon_installed.return_value.state = "stopped" + + # Mock the start addon API call + aioclient_mock.post("http://127.0.0.1/addons/test-two/start", json={"result": "ok"}) + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + await enable_entity(hass, entity_registry, setup_integration, entity_id) + + # Verify initial state is off + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + # Turn on the switch + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": entity_id}, + blocking=True, + ) + + # Verify the API was called + assert aioclient_mock.mock_calls[-1][1].path == "/addons/test-two/start" + assert aioclient_mock.mock_calls[-1][0] == "POST" + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +async def test_switch_turn_off( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, + setup_integration: MockConfigEntry, +) -> None: + """Test turning off addon switch.""" + entity_id = "switch.test" + addon_installed.return_value.state = "started" + + # Mock the stop addon API call + aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"}) + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + await enable_entity(hass, entity_registry, setup_integration, entity_id) + + # Verify initial state is on + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + + # Turn off the switch + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": entity_id}, + blocking=True, + ) + + # Verify the API was called + assert aioclient_mock.mock_calls[-1][1].path == "/addons/test/stop" + assert aioclient_mock.mock_calls[-1][0] == "POST" diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py index 4dbddd46633..1c949bbb2b9 100644 --- a/tests/components/here_travel_time/test_init.py +++ b/tests/components/here_travel_time/test_init.py @@ -18,6 +18,7 @@ from homeassistant.components.here_travel_time.const import ( ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from .const import DEFAULT_CONFIG @@ -80,3 +81,29 @@ async def test_migrate_entry_v1_1_v1_2( assert updated_entry.state is ConfigEntryState.LOADED assert updated_entry.minor_version == 2 assert updated_entry.options[CONF_TRAFFIC_MODE] is True + + +@pytest.mark.usefixtures("valid_response") +async def test_issue_multiple_here_integrations_detected( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that an issue is created when multiple HERE integrations are detected.""" + entry1 = MockConfigEntry( + domain=DOMAIN, + unique_id="1234567890", + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + ) + entry2 = MockConfigEntry( + domain=DOMAIN, + unique_id="0987654321", + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + ) + entry1.add_to_hass(hass) + await hass.config_entries.async_setup(entry1.entry_id) + entry2.add_to_hass(hass) + await hass.config_entries.async_setup(entry2.entry_id) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index f1890073567..4f2c072703a 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -9,7 +9,6 @@ from freezegun import freeze_time import pytest from homeassistant.components import history -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.models import process_timestamp from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE @@ -377,8 +376,9 @@ async def async_record_states( return zero, four, states +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history.""" await async_setup_component(hass, "history", {}) @@ -389,9 +389,9 @@ async def test_fetch_period_api( assert response.status == HTTPStatus.OK +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_with_use_include_order( hass: HomeAssistant, - recorder_mock: Recorder, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, ) -> None: @@ -408,8 +408,9 @@ async def test_fetch_period_api_with_use_include_order( assert "The 'use_include_order' option is deprecated" in caplog.text +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_with_minimal_response( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history with minimal_response.""" now = dt_util.utcnow() @@ -450,8 +451,9 @@ async def test_fetch_period_api_with_minimal_response( ).replace('"', "") +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_with_no_timestamp( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history with no timestamp.""" await async_setup_component(hass, "history", {}) @@ -460,9 +462,9 @@ async def test_fetch_period_api_with_no_timestamp( assert response.status == HTTPStatus.OK +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_with_include_order( hass: HomeAssistant, - recorder_mock: Recorder, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, ) -> None: @@ -488,8 +490,9 @@ async def test_fetch_period_api_with_include_order( assert "The 'include' option is deprecated" in caplog.text +@pytest.mark.usefixtures("recorder_mock") async def test_entity_ids_limit_via_api( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test limiting history to entity_ids.""" await async_setup_component( @@ -514,8 +517,9 @@ async def test_entity_ids_limit_via_api( assert response_json[1][0]["entity_id"] == "light.cow" +@pytest.mark.usefixtures("recorder_mock") async def test_entity_ids_limit_via_api_with_skip_initial_state( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test limiting history to entity_ids with skip_initial_state.""" await async_setup_component( @@ -548,8 +552,9 @@ async def test_entity_ids_limit_via_api_with_skip_initial_state( assert response_json[1][0]["entity_id"] == "light.cow" +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_before_history_started( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history for the far past.""" await async_setup_component( @@ -569,8 +574,9 @@ async def test_fetch_period_api_before_history_started( assert response_json == [] +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_far_future( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history for the far future.""" await async_setup_component( @@ -590,8 +596,9 @@ async def test_fetch_period_api_far_future( assert response_json == [] +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_with_invalid_datetime( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history with an invalid date time.""" await async_setup_component( @@ -609,8 +616,9 @@ async def test_fetch_period_api_with_invalid_datetime( assert response_json == {"message": "Invalid datetime"} +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_invalid_end_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history with an invalid end time.""" await async_setup_component( @@ -631,8 +639,9 @@ async def test_fetch_period_api_invalid_end_time( assert response_json == {"message": "Invalid end_time"} +@pytest.mark.usefixtures("recorder_mock") async def test_entity_ids_limit_via_api_with_end_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test limiting history to entity_ids with end_time.""" await async_setup_component( @@ -677,8 +686,9 @@ async def test_entity_ids_limit_via_api_with_end_time( assert response_json[1][0]["entity_id"] == "light.cow" +@pytest.mark.usefixtures("recorder_mock") async def test_fetch_period_api_with_no_entity_ids( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history with minimal_response.""" await async_setup_component(hass, "history", {}) @@ -730,9 +740,9 @@ async def test_fetch_period_api_with_no_entity_ids( ("cow", HTTPStatus.BAD_REQUEST, "message", "Invalid filter_entity_id"), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_history_with_invalid_entity_ids( hass: HomeAssistant, - recorder_mock: Recorder, hass_client: ClientSessionGenerator, filter_entity_id, status_code, diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 01b49ad5575..a4d47f19c4d 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -9,7 +9,6 @@ import pytest from homeassistant.components import history from homeassistant.components.history import websocket_api -from homeassistant.components.recorder import Recorder from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_state_change_event @@ -39,8 +38,9 @@ def test_setup() -> None: # Verification occurs in the fixture +@pytest.mark.usefixtures("recorder_mock") async def test_history_during_period( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history_during_period.""" now = dt_util.utcnow() @@ -173,8 +173,9 @@ async def test_history_during_period( assert sensor_test_history[2]["a"] == {"any": "attr"} +@pytest.mark.usefixtures("recorder_mock") async def test_history_during_period_impossible_conditions( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history_during_period returns when condition cannot be true.""" await async_setup_component(hass, "history", {}) @@ -235,9 +236,9 @@ async def test_history_during_period_impossible_conditions( @pytest.mark.parametrize( "time_zone", ["UTC", "Europe/Berlin", "America/Chicago", "US/Hawaii"] ) +@pytest.mark.usefixtures("recorder_mock") async def test_history_during_period_significant_domain( hass: HomeAssistant, - recorder_mock: Recorder, hass_ws_client: WebSocketGenerator, time_zone, ) -> None: @@ -403,8 +404,9 @@ async def test_history_during_period_significant_domain( assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) +@pytest.mark.usefixtures("recorder_mock") async def test_history_during_period_bad_start_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history_during_period bad state time.""" await async_setup_component( @@ -427,8 +429,9 @@ async def test_history_during_period_bad_start_time( assert response["error"]["code"] == "invalid_start_time" +@pytest.mark.usefixtures("recorder_mock") async def test_history_during_period_bad_end_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history_during_period bad end time.""" now = dt_util.utcnow() @@ -454,8 +457,9 @@ async def test_history_during_period_bad_end_time( assert response["error"]["code"] == "invalid_end_time" +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_historical_only( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream.""" now = dt_util.utcnow() @@ -543,8 +547,9 @@ async def test_history_stream_historical_only( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_significant_domain_historical_only( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test the stream with climate domain with historical states only.""" now = dt_util.utcnow() @@ -744,8 +749,9 @@ async def test_history_stream_significant_domain_historical_only( assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_bad_start_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream bad state time.""" await async_setup_component( @@ -768,8 +774,9 @@ async def test_history_stream_bad_start_time( assert response["error"]["code"] == "invalid_start_time" +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_end_time_before_start_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream with an end_time before the start_time.""" end_time = dt_util.utcnow() - timedelta(seconds=2) @@ -796,8 +803,9 @@ async def test_history_stream_end_time_before_start_time( assert response["error"]["code"] == "invalid_end_time" +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_bad_end_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream bad end time.""" now = dt_util.utcnow() @@ -823,8 +831,9 @@ async def test_history_stream_bad_end_time( assert response["error"]["code"] == "invalid_end_time" +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_live_no_attributes_minimal_response( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream with history and live data and no_attributes and minimal_response.""" now = dt_util.utcnow() @@ -916,8 +925,9 @@ async def test_history_stream_live_no_attributes_minimal_response( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_live( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream with history and live data.""" now = dt_util.utcnow() @@ -1029,8 +1039,9 @@ async def test_history_stream_live( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_live_minimal_response( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream with history and live data and minimal_response.""" now = dt_util.utcnow() @@ -1134,8 +1145,9 @@ async def test_history_stream_live_minimal_response( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_live_no_attributes( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream with history and live data and no_attributes.""" now = dt_util.utcnow() @@ -1235,8 +1247,9 @@ async def test_history_stream_live_no_attributes( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_live_no_attributes_minimal_response_specific_entities( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream with history and live data and no_attributes and minimal_response with specific entities.""" now = dt_util.utcnow() @@ -1329,8 +1342,9 @@ async def test_history_stream_live_no_attributes_minimal_response_specific_entit } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_live_with_future_end_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream with history and live data with future end time.""" now = dt_util.utcnow() @@ -1438,9 +1452,9 @@ async def test_history_stream_live_with_future_end_time( @pytest.mark.parametrize("include_start_time_state", [True, False]) +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_before_history_starts( hass: HomeAssistant, - recorder_mock: Recorder, hass_ws_client: WebSocketGenerator, include_start_time_state, ) -> None: @@ -1489,8 +1503,9 @@ async def test_history_stream_before_history_starts( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_for_entity_with_no_possible_changes( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream for future with no possible changes where end time is less than or equal to now.""" await async_setup_component( @@ -1540,8 +1555,9 @@ async def test_history_stream_for_entity_with_no_possible_changes( } +@pytest.mark.usefixtures("recorder_mock") async def test_overflow_queue( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test overflowing the history stream queue.""" now = dt_util.utcnow() @@ -1627,8 +1643,9 @@ async def test_overflow_queue( ) == listeners_without_writes(init_listeners) +@pytest.mark.usefixtures("recorder_mock") async def test_history_during_period_for_invalid_entity_ids( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history_during_period for valid and invalid entity ids.""" now = dt_util.utcnow() @@ -1786,8 +1803,9 @@ async def test_history_during_period_for_invalid_entity_ids( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_for_invalid_entity_ids( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream for invalid and valid entity ids.""" @@ -1964,8 +1982,9 @@ async def test_history_stream_for_invalid_entity_ids( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_historical_only_with_start_time_state_past( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream.""" await async_setup_component( @@ -2075,8 +2094,9 @@ async def test_history_stream_historical_only_with_start_time_state_past( } +@pytest.mark.usefixtures("recorder_mock") async def test_history_stream_live_chained_events( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream with history with a chained event.""" now = dt_util.utcnow() diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py deleted file mode 100644 index c9577e20fcf..00000000000 --- a/tests/components/history/test_websocket_api_schema_32.py +++ /dev/null @@ -1,162 +0,0 @@ -"""The tests the History component websocket_api.""" - -from collections.abc import Generator - -import pytest - -from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from tests.components.recorder.common import ( - async_recorder_block_till_done, - async_wait_recording_done, - old_db_schema, -) -from tests.typing import WebSocketGenerator - - -@pytest.fixture(autouse=True) -def db_schema_32(hass: HomeAssistant) -> Generator[None]: - """Fixture to initialize the db with the old schema 32.""" - with old_db_schema(hass, "32"): - yield - - -async def test_history_during_period( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator -) -> None: - """Test history_during_period.""" - now = dt_util.utcnow() - - await async_setup_component(hass, "history", {}) - await async_setup_component(hass, "sensor", {}) - await async_recorder_block_till_done(hass) - recorder.get_instance(hass).states_meta_manager.active = False - assert recorder.get_instance(hass).schema_version == 32 - - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_wait_recording_done(hass) - - await async_wait_recording_done(hass) - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} - - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - "minimal_response": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 - - sensor_test_history = response["result"]["sensor.test"] - assert len(sensor_test_history) == 3 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) - - assert "a" not in sensor_test_history[1] - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - - assert sensor_test_history[2]["s"] == "on" - assert "a" not in sensor_test_history[2] - - await client.send_json( - { - "id": 3, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 3 - sensor_test_history = response["result"]["sensor.test"] - - assert len(sensor_test_history) == 5 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"any": "attr"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) - - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"any": "attr"} - - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"any": "attr"} - - await client.send_json( - { - "id": 4, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 4 - sensor_test_history = response["result"]["sensor.test"] - - assert len(sensor_test_history) == 3 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"any": "attr"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) - - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"any": "attr"} - - assert sensor_test_history[2]["s"] == "on" - assert sensor_test_history[2]["a"] == {"any": "attr"} diff --git a/tests/components/history_stats/test_config_flow.py b/tests/components/history_stats/test_config_flow.py index 08dbefe7465..5b0756f6c61 100644 --- a/tests/components/history_stats/test_config_flow.py +++ b/tests/components/history_stats/test_config_flow.py @@ -42,11 +42,17 @@ async def test_form( { CONF_NAME: DEFAULT_NAME, CONF_ENTITY_ID: "binary_sensor.test_monitored", - CONF_STATE: ["on"], CONF_TYPE: "count", }, ) await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATE: ["on"], + }, + ) + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -124,11 +130,25 @@ async def test_validation_options( { CONF_NAME: DEFAULT_NAME, CONF_ENTITY_ID: "binary_sensor.test_monitored", - CONF_STATE: ["on"], CONF_TYPE: "count", }, ) await hass.async_block_till_done() + + assert result["step_id"] == "state" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATE: ["on"], + }, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "options" + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -182,12 +202,19 @@ async def test_entry_already_exist( { CONF_NAME: DEFAULT_NAME, CONF_ENTITY_ID: "binary_sensor.test_monitored", - CONF_STATE: ["on"], CONF_TYPE: "count", }, ) await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATE: ["on"], + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -256,6 +283,12 @@ async def test_config_flow_preview_success( CONF_NAME: DEFAULT_NAME, CONF_ENTITY_ID: monitored_entity, CONF_TYPE: CONF_TYPE_COUNT, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_STATE: ["on"], }, ) diff --git a/tests/components/history_stats/test_diagnostics.py b/tests/components/history_stats/test_diagnostics.py new file mode 100644 index 00000000000..8ca68b1622e --- /dev/null +++ b/tests/components/history_stats/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for derivative diagnostics.""" + +import pytest + +from homeassistant.components.history_stats.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("recorder_mock") +async def test_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator, loaded_entry +) -> None: + """Test diagnostics for config entry.""" + + result = await get_diagnostics_for_config_entry(hass, hass_client, loaded_entry) + + assert isinstance(result, dict) + assert result["config_entry"]["domain"] == DOMAIN + assert result["config_entry"]["options"][CONF_NAME] == DEFAULT_NAME + assert ( + result["config_entry"]["options"][CONF_ENTITY_ID] + == "binary_sensor.test_monitored" + ) + assert result["entity"][0]["entity_id"] == "sensor.unnamed_statistics" diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py deleted file mode 100644 index 9e114768b6f..00000000000 --- a/tests/components/home_connect/test_time.py +++ /dev/null @@ -1,474 +0,0 @@ -"""Tests for home_connect time entities.""" - -from collections.abc import Awaitable, Callable -from datetime import time -from http import HTTPStatus -from unittest.mock import AsyncMock, MagicMock - -from aiohomeconnect.model import ( - ArrayOfEvents, - ArrayOfSettings, - EventMessage, - EventType, - GetSetting, - HomeAppliance, - SettingKey, -) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError -import pytest - -from homeassistant.components.automation import ( - DOMAIN as AUTOMATION_DOMAIN, - automations_with_entity, -) -from homeassistant.components.home_connect.const import DOMAIN -from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN, scripts_with_entity -from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - - -@pytest.fixture -def platforms() -> list[str]: - """Fixture to specify platforms to test.""" - return [Platform.TIME] - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) -async def test_paired_depaired_devices_flow( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - client: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - appliance: HomeAppliance, -) -> None: - """Test that removed devices are correctly removed from and added to hass on API events.""" - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) - assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert entity_entries - - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.DEPAIRED, - data=ArrayOfEvents([]), - ) - ] - ) - await hass.async_block_till_done() - - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) - assert not device - for entity_entry in entity_entries: - assert not entity_registry.async_get(entity_entry.entity_id) - - # Now that all everything related to the device is removed, pair it again - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.PAIRED, - data=ArrayOfEvents([]), - ) - ] - ) - await hass.async_block_till_done() - - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) - for entity_entry in entity_entries: - assert entity_registry.async_get(entity_entry.entity_id) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize( - ("appliance", "keys_to_check"), - [ - ( - "Oven", - (SettingKey.BSH_COMMON_ALARM_CLOCK,), - ) - ], - indirect=["appliance"], -) -async def test_connected_devices( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - client: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - appliance: HomeAppliance, - keys_to_check: tuple, -) -> None: - """Test that devices reconnected. - - Specifically those devices whose settings, status, etc. could - not be obtained while disconnected and once connected, the entities are added. - """ - get_settings_original_mock = client.get_settings - - async def get_settings_side_effect(ha_id: str): - if ha_id == appliance.ha_id: - raise HomeConnectApiError( - "SDK.Error.HomeAppliance.Connection.Initialization.Failed" - ) - return await get_settings_original_mock.side_effect(ha_id) - - client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - client.get_settings = get_settings_original_mock - - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) - assert device - for key in keys_to_check: - assert not entity_registry.async_get_entity_id( - Platform.TIME, - DOMAIN, - f"{appliance.ha_id}-{key}", - ) - - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.CONNECTED, - data=ArrayOfEvents([]), - ) - ] - ) - await hass.async_block_till_done() - - for key in keys_to_check: - assert entity_registry.async_get_entity_id( - Platform.TIME, - DOMAIN, - f"{appliance.ha_id}-{key}", - ) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) -async def test_time_entity_availability( - hass: HomeAssistant, - client: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - appliance: HomeAppliance, -) -> None: - """Test if time entities availability are based on the appliance connection state.""" - entity_ids = [ - "time.oven_alarm_clock", - ] - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state - assert state.state != STATE_UNAVAILABLE - - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.DISCONNECTED, - ArrayOfEvents([]), - ) - ] - ) - await hass.async_block_till_done() - - for entity_id in entity_ids: - assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) - - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.CONNECTED, - ArrayOfEvents([]), - ) - ] - ) - await hass.async_block_till_done() - - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state - assert state.state != STATE_UNAVAILABLE - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) -@pytest.mark.parametrize( - ("entity_id", "setting_key"), - [ - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - SettingKey.BSH_COMMON_ALARM_CLOCK, - ), - ], -) -async def test_time_entity_functionality( - hass: HomeAssistant, - client: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - appliance: HomeAppliance, - entity_id: str, - setting_key: SettingKey, -) -> None: - """Test time entity functionality.""" - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - value = 30 - entity_state = hass.states.get(entity_id) - assert entity_state is not None - assert entity_state.state != value - await hass.services.async_call( - TIME_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(second=value), - }, - ) - await hass.async_block_till_done() - client.set_setting.assert_awaited_once_with( - appliance.ha_id, setting_key=setting_key, value=value - ) - assert hass.states.is_state(entity_id, str(time(second=value))) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize( - ("entity_id", "setting_key", "mock_attr"), - [ - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - SettingKey.BSH_COMMON_ALARM_CLOCK, - "set_setting", - ), - ], -) -async def test_time_entity_error( - hass: HomeAssistant, - client_with_exception: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - entity_id: str, - setting_key: SettingKey, - mock_attr: str, -) -> None: - """Test time entity error.""" - client_with_exception.get_settings.side_effect = None - client_with_exception.get_settings.return_value = ArrayOfSettings( - [ - GetSetting( - key=setting_key, - raw_key=setting_key.value, - value=30, - ) - ] - ) - assert await integration_setup(client_with_exception) - assert config_entry.state is ConfigEntryState.LOADED - - with pytest.raises(HomeConnectError): - await getattr(client_with_exception, mock_attr)() - - with pytest.raises( - HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" - ): - await hass.services.async_call( - TIME_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(minute=1), - }, - blocking=True, - ) - assert getattr(client_with_exception, mock_attr).call_count == 2 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) -async def test_create_alarm_clock_deprecation_issue( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - client: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], -) -> None: - """Test that we create an issue when an automation or script is using a alarm clock time entity or the entity is used by the user.""" - entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" - automation_script_issue_id = ( - f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" - ) - action_handler_issue_id = f"deprecated_time_alarm_clock_{entity_id}" - - assert await async_setup_component( - hass, - AUTOMATION_DOMAIN, - { - AUTOMATION_DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - SCRIPT_DOMAIN, - { - SCRIPT_DOMAIN: { - "test": { - "sequence": [ - { - "action": "switch.turn_on", - "entity_id": entity_id, - }, - ], - } - } - }, - ) - - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - await hass.services.async_call( - TIME_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(minute=1), - }, - blocking=True, - ) - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 2 - assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) - assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) - assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) -async def test_alarm_clock_deprecation_issue_fix( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - issue_registry: ir.IssueRegistry, - client: MagicMock, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], -) -> None: - """Test we can fix the issues created when a alarm clock time entity is in an automation or in a script or when is used.""" - entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" - automation_script_issue_id = ( - f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" - ) - action_handler_issue_id = f"deprecated_time_alarm_clock_{entity_id}" - - assert await async_setup_component( - hass, - AUTOMATION_DOMAIN, - { - AUTOMATION_DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - SCRIPT_DOMAIN, - { - SCRIPT_DOMAIN: { - "test": { - "sequence": [ - { - "action": "switch.turn_on", - "entity_id": entity_id, - }, - ], - } - } - }, - ) - - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - await hass.services.async_call( - TIME_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(minute=1), - }, - blocking=True, - ) - - assert len(issue_registry.issues) == 2 - assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) - assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) - - for issue in issue_registry.issues.copy().values(): - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) - assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) - assert len(issue_registry.issues) == 0 diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index ec87672e75c..565fd7113ba 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -105,6 +105,7 @@ async def test_load_preferences(hass: HomeAssistant) -> None: exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] assert exposed_entities._assistants == {} + assert exposed_entities.entities == {} exposed_entities.async_set_expose_new_entities("test1", True) exposed_entities.async_set_expose_new_entities("test2", False) diff --git a/tests/components/homeassistant_connect_zbt2/__init__.py b/tests/components/homeassistant_connect_zbt2/__init__.py new file mode 100644 index 00000000000..298f21ce3f7 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant Connect ZBT-2 integration.""" diff --git a/tests/components/homeassistant_connect_zbt2/common.py b/tests/components/homeassistant_connect_zbt2/common.py new file mode 100644 index 00000000000..78a4b754479 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/common.py @@ -0,0 +1,12 @@ +"""Common constants for the Connect ZBT-2 integration tests.""" + +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +USB_DATA_ZBT2 = UsbServiceInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + vid="303A", + pid="4001", + serial_number="80B54EEFAE18", + manufacturer="Nabu Casa", + description="ZBT-2", +) diff --git a/tests/components/homeassistant_connect_zbt2/conftest.py b/tests/components/homeassistant_connect_zbt2/conftest.py new file mode 100644 index 00000000000..2a4d349debe --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/conftest.py @@ -0,0 +1,59 @@ +"""Test fixtures for the Home Assistant Connect ZBT-2 integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture(name="mock_usb_serial_by_id", autouse=True) +def mock_usb_serial_by_id_fixture() -> Generator[MagicMock]: + """Mock usb serial by id.""" + with patch( + "homeassistant.components.zha.config_flow.usb.get_serial_by_id" + ) as mock_usb_serial_by_id: + mock_usb_serial_by_id.side_effect = lambda x: x + yield mock_usb_serial_by_id + + +@pytest.fixture(autouse=True) +def mock_zha(): + """Mock the zha integration.""" + mock_connect_app = MagicMock() + mock_connect_app.__aenter__.return_value.backups.backups = [MagicMock()] + mock_connect_app.__aenter__.return_value.backups.create_backup.return_value = ( + MagicMock() + ) + + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", + return_value=mock_connect_app, + ), + patch( + "homeassistant.components.zha.async_setup_entry", + return_value=True, + ), + ): + yield + + +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + +@pytest.fixture(autouse=True) +def mock_usb_path_exists() -> Generator[None]: + """Mock os.path.exists to allow the Connect ZBT-2 integration to load.""" + with patch( + "homeassistant.components.homeassistant_connect_zbt2.os.path.exists", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py new file mode 100644 index 00000000000..dc32741165e --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -0,0 +1,384 @@ +"""Test the Home Assistant Connect ZBT-2 config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, call, patch + +import pytest + +from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( + STEP_PICK_FIRMWARE_THREAD, + STEP_PICK_FIRMWARE_ZIGBEE, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from .common import USB_DATA_ZBT2 + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="supervisor") +def mock_supervisor_fixture() -> Generator[None]: + """Mock Supervisor.""" + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", + return_value=True, + ): + yield + + +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_connect_zbt2.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_config_flow_zigbee( + hass: HomeAssistant, +) -> None: + """Test Zigbee config flow for Connect ZBT-2.""" + fw_type = ApplicationType.EZSP + fw_version = "7.4.4.0 build 0" + model = "Home Assistant Connect ZBT-2" + usb_data = USB_DATA_ZBT2 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=usb_data + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["model"] == model + + async def mock_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: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) + return await getattr(self, f"async_step_{next_step_id}")() + + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", + autospec=True, + side_effect=mock_install_firmware_step, + ), + ): + pick_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + create_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": fw_type.value, + "firmware_version": fw_version, + "device": usb_data.device, + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + } + + flows = hass.config_entries.flow.async_progress() + + # Ensure a ZHA discovery flow has been created + assert len(flows) == 1 + zha_flow = flows[0] + assert zha_flow["handler"] == "zha" + assert zha_flow["context"]["source"] == "hardware" + assert zha_flow["step_id"] == "confirm" + + +@pytest.mark.usefixtures("addon_installed", "supervisor") +async def test_config_flow_thread( + hass: HomeAssistant, + start_addon: AsyncMock, +) -> None: + """Test Thread config flow for Connect ZBT-2.""" + fw_type = ApplicationType.SPINEL + fw_version = "2.4.4.0" + model = "Home Assistant Connect ZBT-2" + usb_data = USB_DATA_ZBT2 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=usb_data + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["model"] == model + + async def mock_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: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) + return await getattr(self, f"async_step_{next_step_id}")() + + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", + autospec=True, + side_effect=mock_install_firmware_step, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_otbr_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + create_result = await hass.config_entries.flow.async_configure( + result["flow_id"] + ) + + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": fw_type.value, + "firmware_version": fw_version, + "device": usb_data.device, + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + } + + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 0 + + +@pytest.mark.parametrize( + ("usb_data", "model"), + [ + (USB_DATA_ZBT2, "Home Assistant Connect ZBT-2"), + ], +) +async def test_options_flow( + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant +) -> None: + """Test the options flow for Connect ZBT-2.""" + config_entry = MockConfigEntry( + domain="homeassistant_connect_zbt2", + data={ + "firmware": "spinel", + "firmware_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "device": usb_data.device, + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + }, + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # First step is confirmation + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "spinel" + assert description_placeholders["model"] == model + + mock_update_client = AsyncMock() + mock_manifest = Mock() + mock_firmware = Mock() + mock_firmware.filename = "zbt2_zigbee_ncp_7.4.4.0.gbl" + mock_firmware.metadata = { + "ezsp_version": "7.4.4.0", + "fw_type": "zbt2_zigbee_ncp", + "metadata_version": 2, + } + mock_manifest.firmwares = [mock_firmware] + mock_update_client.async_update_data.return_value = mock_manifest + mock_update_client.async_fetch_firmware.return_value = b"firmware_data" + + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[], + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.FirmwareUpdateClient", + return_value=mock_update_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware", + return_value=FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ), + ) as flash_mock, + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + side_effect=[ + # First call: probe before installation (returns current SPINEL firmware) + FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.SPINEL, + firmware_version="2.4.4.0", + owners=[], + source="probe", + ), + # Second call: probe after installation (returns new EZSP firmware) + FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ), + ], + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + ): + pick_result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + create_result = await hass.config_entries.options.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + assert config_entry.data == { + "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", + "device": usb_data.device, + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + } + + # Verify async_flash_silabs_firmware was called with ZBT-2's reset methods + assert flash_mock.call_count == 1 + assert flash_mock.mock_calls[0].kwargs["bootloader_reset_methods"] == ["rts_dtr"] + + +async def test_duplicate_discovery(hass: HomeAssistant) -> None: + """Test config flow unique_id deduplication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 + ) + + assert result["type"] is FlowResultType.MENU + + result_duplicate = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 + ) + + assert result_duplicate["type"] is FlowResultType.ABORT + assert result_duplicate["reason"] == "already_in_progress" + + +async def test_duplicate_discovery_updates_usb_path(hass: HomeAssistant) -> None: + """Test config flow unique_id deduplication updates USB path.""" + config_entry = MockConfigEntry( + domain="homeassistant_connect_zbt2", + data={ + "firmware": "spinel", + "firmware_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "device": "/dev/oldpath", + "manufacturer": USB_DATA_ZBT2.manufacturer, + "pid": USB_DATA_ZBT2.pid, + "product": USB_DATA_ZBT2.description, + "serial_number": USB_DATA_ZBT2.serial_number, + "vid": USB_DATA_ZBT2.vid, + }, + version=1, + minor_version=1, + unique_id=( + f"{USB_DATA_ZBT2.vid}:{USB_DATA_ZBT2.pid}_" + f"{USB_DATA_ZBT2.serial_number}_" + f"{USB_DATA_ZBT2.manufacturer}_" + f"{USB_DATA_ZBT2.description}" + ), + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert config_entry.data["device"] == USB_DATA_ZBT2.device diff --git a/tests/components/homeassistant_connect_zbt2/test_hardware.py b/tests/components/homeassistant_connect_zbt2/test_hardware.py new file mode 100644 index 00000000000..030a2610d64 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_hardware.py @@ -0,0 +1,66 @@ +"""Test the Home Assistant Connect ZBT-2 hardware platform.""" + +from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + +CONFIG_ENTRY_DATA = { + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", +} + + +async def test_hardware_info( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info +) -> None: + """Test we can get the board info.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + + # Setup the config entry + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=DOMAIN, + options={}, + title="Home Assistant Connect ZBT-2", + unique_id="unique_1", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == { + "hardware": [ + { + "board": None, + "config_entries": [config_entry.entry_id], + "dongle": { + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "description": "ZBT-2", + }, + "name": "Home Assistant Connect ZBT-2", + "url": "https://support.nabucasa.com/hc/en-us/categories/24734620813469-Home-Assistant-Connect-ZBT-1", + } + ] + } diff --git a/tests/components/homeassistant_connect_zbt2/test_init.py b/tests/components/homeassistant_connect_zbt2/test_init.py new file mode 100644 index 00000000000..42f5f8ac5a5 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_init.py @@ -0,0 +1,135 @@ +"""Test the Home Assistant Connect ZBT-2 integration.""" + +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.usb import USBDevice +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.usb import ( + async_request_scan, + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) + + +async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: + """Test setup failing when the USB port is missing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=1, + ) + + config_entry.add_to_hass(hass) + + # Set up the config entry + with patch( + "homeassistant.components.homeassistant_connect_zbt2.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_exists.return_value = True + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now it's ready + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("force_usb_polling_watcher") +async def test_usb_device_reactivity(hass: HomeAssistant) -> None: + """Test setting up USB monitoring.""" + assert await async_setup_component(hass, "usb", {"usb": {}}) + + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=1, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_connect_zbt2.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + # Now we make it available but do not wait + mock_exists.return_value = True + + with patch_scanned_serial_ports( + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + vid="303A", + pid="4001", + serial_number="80B54EEFAE18", + manufacturer="Nabu Casa", + description="ZBT-2", + ) + ], + ): + await async_request_scan(hass) + + # It loads immediately + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Wait for a bit for the USB scan debouncer to cool off + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=5)) + + # Unplug the stick + mock_exists.return_value = False + + with patch_scanned_serial_ports(return_value=[]): + await async_request_scan(hass) + + # The integration has reloaded and is now in a failed state + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/homeassistant_connect_zbt2/test_update.py b/tests/components/homeassistant_connect_zbt2/test_update.py new file mode 100644 index 00000000000..463caf65686 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_update.py @@ -0,0 +1,131 @@ +"""Test Connect ZBT-2 firmware update entity.""" + +import pytest + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import USB_DATA_ZBT2 + +from tests.common import MockConfigEntry + +UPDATE_ENTITY_ID = "update.home_assistant_connect_zbt_2_80b54eefae18_firmware" + + +async def test_zbt2_update_entity(hass: HomeAssistant) -> None: + """Test the ZBT-2 firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the ZBT-2 integration + zbt2_config_entry = MockConfigEntry( + domain="homeassistant_connect_zbt2", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": USB_DATA_ZBT2.device, + "manufacturer": USB_DATA_ZBT2.manufacturer, + "pid": USB_DATA_ZBT2.pid, + "product": USB_DATA_ZBT2.description, + "serial_number": USB_DATA_ZBT2.serial_number, + "vid": USB_DATA_ZBT2.vid, + }, + version=1, + minor_version=1, + ) + zbt2_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt2_config_entry.entry_id) + await hass.async_block_till_done() + + # Pretend ZHA loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=USB_DATA_ZBT2.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp is not None + assert state_ezsp.state == "unknown" + assert state_ezsp.attributes["title"] == "EmberZNet Zigbee" + assert state_ezsp.attributes["installed_version"] == "7.3.1.0" + assert state_ezsp.attributes["latest_version"] is None + + # Now, have OTBR push some info + await async_notify_firmware_info( + hass, + "otbr", + FirmwareInfo( + device=USB_DATA_ZBT2.device, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="otbr", + ), + ) + await hass.async_block_till_done() + + # After the firmware update, the entity has the new version and the correct state + state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel is not None + assert state_spinel.state == "unknown" + assert state_spinel.attributes["title"] == "OpenThread RCP" + assert state_spinel.attributes["installed_version"] == "2.4.4.0" + assert state_spinel.attributes["latest_version"] is None + + +@pytest.mark.parametrize( + ("firmware", "version", "expected"), + [ + ("ezsp", "7.3.1.0 build 0", "EmberZNet Zigbee 7.3.1.0"), + ("spinel", "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", "OpenThread RCP 2.4.4.0"), + ("bootloader", "2.4.2", "Gecko Bootloader 2.4.2"), + ("router", "1.2.3.4", "Unknown 1.2.3.4"), # Not supported but still shown + ], +) +async def test_zbt2_update_entity_state( + hass: HomeAssistant, firmware: str, version: str, expected: str +) -> None: + """Test the ZBT-2 firmware update entity with different firmware types.""" + await async_setup_component(hass, "homeassistant", {}) + + zbt2_config_entry = MockConfigEntry( + domain="homeassistant_connect_zbt2", + data={ + "firmware": firmware, + "firmware_version": version, + "device": USB_DATA_ZBT2.device, + "manufacturer": USB_DATA_ZBT2.manufacturer, + "pid": USB_DATA_ZBT2.pid, + "product": USB_DATA_ZBT2.description, + "serial_number": USB_DATA_ZBT2.serial_number, + "vid": USB_DATA_ZBT2.vid, + }, + version=1, + minor_version=1, + ) + zbt2_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt2_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert ( + f"{state.attributes['title']} {state.attributes['installed_version']}" + == expected + ) diff --git a/tests/components/homeassistant_connect_zbt2/test_util.py b/tests/components/homeassistant_connect_zbt2/test_util.py new file mode 100644 index 00000000000..8541c880b00 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_util.py @@ -0,0 +1,36 @@ +"""Test Connect ZBT-2 utilities.""" + +from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.homeassistant_connect_zbt2.util import ( + get_usb_service_info, +) +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from tests.common import MockConfigEntry + +CONNECT_ZBT2_CONFIG_ENTRY = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + "firmware": "ezsp", + }, + version=2, +) + + +def test_get_usb_service_info() -> None: + """Test `get_usb_service_info` conversion.""" + assert get_usb_service_info(CONNECT_ZBT2_CONFIG_ENTRY) == UsbServiceInfo( + device=CONNECT_ZBT2_CONFIG_ENTRY.data["device"], + vid=CONNECT_ZBT2_CONFIG_ENTRY.data["vid"], + pid=CONNECT_ZBT2_CONFIG_ENTRY.data["pid"], + serial_number=CONNECT_ZBT2_CONFIG_ENTRY.data["serial_number"], + manufacturer=CONNECT_ZBT2_CONFIG_ENTRY.data["manufacturer"], + description=CONNECT_ZBT2_CONFIG_ENTRY.data["product"], + ) diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index ddf18305b2a..9da3371bfae 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -27,7 +27,7 @@ def mock_zha_config_flow_setup() -> Generator[None]: side_effect=mock_probe, ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index d5039f3b0bd..267fa389d91 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -1,11 +1,12 @@ """Test the Home Assistant hardware firmware config flow.""" import asyncio -from collections.abc import Awaitable, Callable, Generator, Iterator +from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Sequence import contextlib from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from aiohasupervisor.models import AddonsOptions from aiohttp import ClientError from ha_silabs_firmware_client import ( FirmwareManifest, @@ -15,7 +16,6 @@ from ha_silabs_firmware_client import ( import pytest from yarl import URL -from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -25,9 +25,15 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, - get_otbr_addon_manager, + ResetTarget, +) +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_USER, + ConfigEntry, + ConfigFlowResult, + OptionsFlow, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError @@ -35,6 +41,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import ( + ANY, MockConfigEntry, MockModule, mock_config_flow, @@ -76,7 +83,7 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): ) -> ConfigFlowResult: """Install Zigbee firmware.""" return await self._install_firmware_step( - fw_update_url=TEST_RELEASES_URL, + fw_update_url=str(TEST_RELEASES_URL), fw_type="fake_zigbee_ncp", firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, @@ -89,12 +96,12 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): ) -> ConfigFlowResult: """Install Thread firmware.""" return await self._install_firmware_step( - fw_update_url=TEST_RELEASES_URL, + fw_update_url=str(TEST_RELEASES_URL), fw_type="fake_openthread_rcp", firmware_name="Thread", expected_installed_firmware_type=ApplicationType.SPINEL, step_id="install_thread_firmware", - next_step_id="start_otbr_addon", + next_step_id="finish_thread_installation", ) def _async_flow_finished(self) -> ConfigFlowResult: @@ -138,13 +145,27 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Install Zigbee firmware.""" - return await self.async_step_pre_confirm_zigbee() + return await self._install_firmware_step( + fw_update_url=str(TEST_RELEASES_URL), + fw_type="fake_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.async_step_start_otbr_addon() + return await self._install_firmware_step( + fw_update_url=str(TEST_RELEASES_URL), + fw_type="fake_openthread_rcp", + firmware_name="Thread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="finish_thread_installation", + ) def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" @@ -165,7 +186,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): @pytest.fixture(autouse=True) async def mock_test_firmware_platform( hass: HomeAssistant, -) -> Generator[None]: +) -> AsyncGenerator[None]: """Fixture for a test config flow.""" mock_module = MockModule( TEST_DOMAIN, async_setup_entry=AsyncMock(return_value=True) @@ -205,42 +226,20 @@ def create_mock_owner() -> Mock: @contextlib.contextmanager def mock_firmware_info( - hass: HomeAssistant, *, is_hassio: bool = True, probe_app_type: ApplicationType | None = ApplicationType.EZSP, probe_fw_version: str | None = "2.4.4.0", - otbr_addon_info: AddonInfo = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ), flash_app_type: ApplicationType = ApplicationType.EZSP, flash_fw_version: str | None = "7.4.4.0", -) -> Iterator[tuple[Mock, Mock]]: - """Mock the main addon states for the config flow.""" - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info - +) -> Iterator[Mock]: + """Mock the firmware info.""" mock_update_client = AsyncMock(spec_set=FirmwareUpdateClient) mock_update_client.async_update_data.return_value = FirmwareManifest( url=TEST_RELEASES_URL, html_url=TEST_RELEASES_URL / "html", created_at=utcnow(), - firmwares=[ + firmwares=( FirmwareMetadata( filename="fake_openthread_rcp_7.4.4.0_variant.gbl", checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", @@ -271,7 +270,7 @@ def mock_firmware_info( }, url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl", ), - ], + ), ) if probe_app_type is None: @@ -301,7 +300,7 @@ def mock_firmware_info( 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: await asyncio.sleep(0) @@ -317,14 +316,6 @@ def mock_firmware_info( return flashed_firmware_info with ( - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", return_value=is_hassio, @@ -350,13 +341,13 @@ def mock_firmware_info( side_effect=mock_flash_firmware, ), ): - yield mock_otbr_manager, mock_update_client + yield mock_update_client async def consume_progress_flow( hass: HomeAssistant, flow_id: str, - valid_step_ids: tuple[str], + valid_step_ids: tuple[str, ...], ) -> ConfigFlowResult: """Consume a progress flow until it is done.""" while True: @@ -374,8 +365,8 @@ async def consume_progress_flow( return result -async def test_config_flow_zigbee(hass: HomeAssistant) -> None: - """Test the config flow.""" +async def test_config_flow_zigbee_recommended(hass: HomeAssistant) -> None: + """Test flow with recommended Zigbee installation type.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) @@ -384,7 +375,6 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert init_result["step_id"] == "pick_firmware" with mock_firmware_info( - hass, probe_app_type=ApplicationType.SPINEL, flash_app_type=ApplicationType.EZSP, ): @@ -394,22 +384,24 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + assert pick_result["type"] is FlowResultType.SHOW_PROGRESS assert pick_result["progress_action"] == "install_firmware" assert pick_result["step_id"] == "install_zigbee_firmware" - confirm_result = await consume_progress_flow( + create_result = await consume_progress_flow( hass, flow_id=pick_result["flow_id"], valid_step_ids=("install_zigbee_firmware",), ) - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == "confirm_zigbee" - - create_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], user_input={} - ) assert create_result["type"] is FlowResultType.CREATE_ENTRY config_entry = create_result["result"] @@ -427,6 +419,175 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert zha_flow["context"]["source"] == "hardware" assert zha_flow["step_id"] == "confirm" + progress_zha_flows = hass.config_entries.flow._async_progress_by_handler( + handler="zha", + match_context=None, + ) + + assert len(progress_zha_flows) == 1 + + progress_zha_flow = progress_zha_flows[0] + assert progress_zha_flow.init_data == { + "name": "Some Hardware Name", + "port": { + "path": "/dev/SomeDevice123", + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "ezsp", + "flow_strategy": "recommended", + } + + +async def test_config_flow_zigbee_custom_zha(hass: HomeAssistant) -> None: + """Test flow with custom Zigbee installation type and ZHA selected.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + with mock_firmware_info( + probe_app_type=ApplicationType.SPINEL, + flash_app_type=ApplicationType.EZSP, + ): + # Pick the menu option: we are flashing the firmware + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_custom"}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_integration" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_integration_zha"}, + ) + + assert pick_result["type"] is FlowResultType.SHOW_PROGRESS + assert pick_result["progress_action"] == "install_firmware" + assert pick_result["step_id"] == "install_zigbee_firmware" + + create_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=("install_zigbee_firmware",), + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": "ezsp", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } + + # Ensure a ZHA discovery flow has been created + flows = hass.config_entries.flow.async_progress() + assert flows == [ + { + "context": { + "confirm_only": True, + "source": "hardware", + "title_placeholders": { + "name": "Some Hardware Name", + }, + "unique_id": "Some Hardware Name_ezsp_/dev/SomeDevice123", + }, + "flow_id": ANY, + "handler": "zha", + "step_id": "confirm", + } + ] + + progress_zha_flows = hass.config_entries.flow._async_progress_by_handler( + handler="zha", + match_context=None, + ) + + assert len(progress_zha_flows) == 1 + + progress_zha_flow = progress_zha_flows[0] + assert progress_zha_flow.init_data == { + "name": "Some Hardware Name", + "port": { + "path": "/dev/SomeDevice123", + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "ezsp", + "flow_strategy": "advanced", + } + + +async def test_config_flow_zigbee_custom_other(hass: HomeAssistant) -> None: + """Test flow with custom Zigbee installation type and Other selected.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + with mock_firmware_info( + probe_app_type=ApplicationType.SPINEL, + flash_app_type=ApplicationType.EZSP, + ): + # Pick the menu option: we are flashing the firmware + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_custom"}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_integration" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_integration_other"}, + ) + + assert pick_result["type"] is FlowResultType.SHOW_PROGRESS + assert pick_result["progress_action"] == "install_firmware" + assert pick_result["step_id"] == "install_zigbee_firmware" + + create_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=("install_zigbee_firmware",), + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": "ezsp", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } + + flows = hass.config_entries.flow.async_progress() + assert flows == [] + async def test_config_flow_firmware_index_download_fails_but_not_required( hass: HomeAssistant, @@ -436,13 +597,15 @@ async def test_config_flow_firmware_index_download_fails_but_not_required( TEST_DOMAIN, context={"source": "hardware"} ) + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, # The correct firmware is already installed probe_app_type=ApplicationType.EZSP, # An older version is probed, so an upgrade is attempted probe_fw_version="7.4.3.0", - ) as (_, mock_update_client): + ) as mock_update_client: # Mock the firmware download to fail mock_update_client.async_update_data.side_effect = ClientError() @@ -451,8 +614,15 @@ async def test_config_flow_firmware_index_download_fails_but_not_required( user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert pick_result["type"] is FlowResultType.FORM - assert pick_result["step_id"] == "confirm_zigbee" + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_config_flow_firmware_download_fails_but_not_required( @@ -463,15 +633,15 @@ async def test_config_flow_firmware_download_fails_but_not_required( TEST_DOMAIN, context={"source": "hardware"} ) - with ( - mock_firmware_info( - hass, - # The correct firmware is already installed so installation isn't required - probe_app_type=ApplicationType.EZSP, - # An older version is probed, so an upgrade is attempted - probe_fw_version="7.4.3.0", - ) as (_, mock_update_client), - ): + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + with mock_firmware_info( + # The correct firmware is already installed so installation isn't required + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as mock_update_client: mock_update_client.async_fetch_firmware.side_effect = ClientError() pick_result = await hass.config_entries.flow.async_configure( @@ -479,8 +649,15 @@ async def test_config_flow_firmware_download_fails_but_not_required( user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert pick_result["type"] is FlowResultType.FORM - assert pick_result["step_id"] == "confirm_zigbee" + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_config_flow_doesnt_downgrade( @@ -491,9 +668,11 @@ async def test_config_flow_doesnt_downgrade( TEST_DOMAIN, context={"source": "hardware"} ) + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + with ( mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, # An newer version is probed than what we offer probe_fw_version="7.5.0.0", @@ -507,14 +686,20 @@ async def test_config_flow_doesnt_downgrade( user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert pick_result["type"] is FlowResultType.FORM - assert pick_result["step_id"] == "confirm_zigbee" + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(mock_async_flash_silabs_firmware.mock_calls) == 0 async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> None: - """Test the config flow, skip installing the addon if necessary.""" + """Test skip installing the firmware if not needed.""" result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) @@ -522,25 +707,30 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - with mock_firmware_info(hass, probe_app_type=ApplicationType.SPINEL): + with mock_firmware_info( + probe_app_type=ApplicationType.SPINEL, + ): # Pick the menu option: we skip installation, instead we directly run it result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - # Confirm - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "zigbee_installation_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) # Done with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, ): await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: @@ -569,7 +759,12 @@ async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: } -async def test_config_flow_thread(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("addon_installed") +async def test_config_flow_thread( + hass: HomeAssistant, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: """Test the config flow.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} @@ -579,10 +774,9 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: assert init_result["step_id"] == "pick_firmware" with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, _): + ): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -590,29 +784,17 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: ) assert pick_result["type"] is FlowResultType.SHOW_PROGRESS - assert pick_result["progress_action"] == "install_addon" - assert pick_result["step_id"] == "install_otbr_addon" - assert pick_result["description_placeholders"]["firmware_type"] == "ezsp" - assert pick_result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + assert pick_result["progress_action"] == "install_firmware" + assert pick_result["step_id"] == "install_thread_firmware" + description_placeholders = pick_result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "ezsp" + assert description_placeholders["model"] == TEST_HARDWARE_NAME await hass.async_block_till_done(wait_background_tasks=True) - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={ - "device": "", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.2.3", - ) - # Progress the flow, it is now installing firmware - confirm_otbr_result = await consume_progress_flow( + create_result = await consume_progress_flow( hass, flow_id=pick_result["flow_id"], valid_step_ids=( @@ -624,9 +806,6 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: ) # Installation will conclude with the config entry being created - create_result = await hass.config_entries.flow.async_configure( - confirm_otbr_result["flow_id"], user_input={} - ) assert create_result["type"] is FlowResultType.CREATE_ENTRY config_entry = create_result["result"] @@ -636,37 +815,36 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: "hardware": TEST_HARDWARE_NAME, } - assert mock_otbr_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", "baudrate": 460800, "flow_control": True, "autoflash_firmware": False, - } - ) - ] + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") -async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("addon_installed") +async def test_config_flow_thread_addon_already_installed( + hass: HomeAssistant, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: """Test the Thread config flow, addon is already installed.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_RUNNING, - update_available=False, - version=None, - ), - ) as (mock_otbr_manager, _): + ): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -674,7 +852,7 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - ) # Progress - confirm_otbr_result = await consume_progress_flow( + create_result = await consume_progress_flow( hass, flow_id=pick_result["flow_id"], valid_step_ids=( @@ -684,36 +862,35 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - ), ) - # We're now waiting to confirm OTBR - assert confirm_otbr_result["type"] is FlowResultType.FORM - assert confirm_otbr_result["step_id"] == "confirm_otbr" - - # The addon has been installed - assert mock_otbr_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, # And firmware flashing is disabled - } - ) - ] - - # Finally, create the config entry - create_result = await hass.config_entries.flow.async_configure( - confirm_otbr_result["flow_id"], user_input={} - ) - assert create_result["type"] is FlowResultType.CREATE_ENTRY - assert create_result["result"].data == { - "firmware": "spinel", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } + # The add-on has been installed + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", + "baudrate": 460800, + "flow_control": True, + "autoflash_firmware": False, + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"].data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } -@pytest.mark.usefixtures("addon_store_info") -async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("addon_not_installed") +async def test_options_flow_zigbee_to_thread( + hass: HomeAssistant, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: """Test the options flow, migrating Zigbee to Thread.""" config_entry = MockConfigEntry( domain=TEST_DOMAIN, @@ -730,16 +907,16 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, _): - # First step is confirmation + ): result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "ezsp" - assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "ezsp" + assert description_placeholders["model"] == TEST_HARDWARE_NAME result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -747,58 +924,49 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_otbr_addon" + assert result["step_id"] == "install_thread_firmware" + assert result["progress_action"] == "install_firmware" await hass.async_block_till_done(wait_background_tasks=True) - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={ - "device": "", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.2.3", - ) + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_otbr_addon" + assert result["progress_action"] == "install_otbr_addon" + + await hass.async_block_till_done(wait_background_tasks=True) - # Progress the flow, it is now configuring the addon and running it result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_otbr_addon" assert result["progress_action"] == "start_otbr_addon" - assert mock_otbr_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - } - ) - ] - await hass.async_block_till_done(wait_background_tasks=True) - # The addon is now running result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - # We are now done - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert install_addon.call_count == 1 + assert install_addon.call_args == call("core_openthread_border_router") + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", + "baudrate": 460800, + "flow_control": True, + "autoflash_firmware": False, + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") + assert result["type"] is FlowResultType.CREATE_ENTRY - # The firmware type has been updated - assert config_entry.data["firmware"] == "spinel" + # The firmware type has been updated + assert config_entry.data["firmware"] == "spinel" @pytest.mark.usefixtures("addon_store_info") @@ -818,36 +986,262 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) - # First step is confirmation result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "spinel" - assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "spinel" + assert description_placeholders["model"] == TEST_HARDWARE_NAME with mock_firmware_info( - hass, probe_app_type=ApplicationType.SPINEL, ): - # Pick the menu option: we are now installing the addon - result = await hass.config_entries.options.async_configure( + pick_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, ): # We are now done result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_zigbee_firmware" + assert result["progress_action"] == "install_firmware" + + await hass.async_block_till_done(wait_background_tasks=True) + + create_result = await hass.config_entries.options.async_configure( + result["flow_id"] + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY # The firmware type has been updated assert config_entry.data["firmware"] == "ezsp" + + +async def test_config_flow_pick_firmware_shows_migrate_options_with_existing_zha( + hass: HomeAssistant, +) -> None: + """Test that migrate options are shown when ZHA entries exist.""" + # Create a ZHA config entry + zha_entry = MockConfigEntry( + domain="zha", + data={"device": {"path": "/dev/ttyUSB1"}}, + title="ZHA", + ) + zha_entry.add_to_hass(hass) + + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + # Should show migrate option for Zigbee since ZHA exists (migrating from ZHA to Zigbee) + menu_options = init_result["menu_options"] + assert "pick_firmware_zigbee_migrate" in menu_options + assert "pick_firmware_thread" in menu_options # Normal option for Thread + + +async def test_config_flow_pick_firmware_shows_migrate_options_with_existing_otbr( + hass: HomeAssistant, +) -> None: + """Test that migrate options are shown when OTBR entries exist.""" + # Create an OTBR config entry + otbr_entry = MockConfigEntry( + domain="otbr", + data={"url": "http://192.168.1.100:8081"}, + title="OpenThread Border Router", + ) + otbr_entry.add_to_hass(hass) + + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + # Should show migrate option for Thread since OTBR exists (migrating from OTBR to Thread) + menu_options = init_result["menu_options"] + assert "pick_firmware_thread_migrate" in menu_options + assert "pick_firmware_zigbee" in menu_options # Normal option for Zigbee + + +async def test_config_flow_pick_firmware_shows_migrate_options_with_both_existing( + hass: HomeAssistant, +) -> None: + """Test that migrate options are shown when both ZHA and OTBR entries exist.""" + # Create both ZHA and OTBR config entries + zha_entry = MockConfigEntry( + domain="zha", + data={"device": {"path": "/dev/ttyUSB1"}}, + title="ZHA", + ) + zha_entry.add_to_hass(hass) + + otbr_entry = MockConfigEntry( + domain="otbr", + data={"url": "http://192.168.1.100:8081"}, + title="OpenThread Border Router", + ) + otbr_entry.add_to_hass(hass) + + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + # Should show migrate options for both since both exist + menu_options = init_result["menu_options"] + assert "pick_firmware_zigbee_migrate" in menu_options + assert "pick_firmware_thread_migrate" in menu_options + + +async def test_config_flow_pick_firmware_shows_normal_options_without_existing( + hass: HomeAssistant, +) -> None: + """Test that normal options are shown when no ZHA or OTBR entries exist.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + # Should show normal options since no existing entries + menu_options = init_result["menu_options"] + assert "pick_firmware_zigbee" in menu_options + assert "pick_firmware_thread" in menu_options + assert "pick_firmware_zigbee_migrate" not in menu_options + assert "pick_firmware_thread_migrate" not in menu_options + + +async def test_config_flow_zigbee_migrate_handler(hass: HomeAssistant) -> None: + """Test that the Zigbee migrate handler works correctly.""" + # Ensure Zigbee migrate option is available by adding a ZHA entry + zha_entry = MockConfigEntry( + domain="zha", + data={"device": {"path": "/dev/ttyUSB1"}}, + title="ZHA", + ) + zha_entry.add_to_hass(hass) + + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_firmware_info( + probe_app_type=ApplicationType.SPINEL, + flash_app_type=ApplicationType.EZSP, + ): + # Test the migrate handler directly + result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": "pick_firmware_zigbee_migrate"}, + ) + + # Should proceed to zigbee installation type (same as normal zigbee flow) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "zigbee_installation_type" + + +@pytest.mark.usefixtures("addon_installed") +async def test_config_flow_thread_migrate_handler(hass: HomeAssistant) -> None: + """Test that the Thread migrate handler works correctly.""" + # Ensure Thread migrate option is available by adding an OTBR entry + otbr_entry = MockConfigEntry( + domain="otbr", + data={"url": "http://192.168.1.100:8081"}, + title="OpenThread Border Router", + ) + otbr_entry.add_to_hass(hass) + + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_firmware_info( + probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, + ): + # Test the migrate handler directly + result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": "pick_firmware_thread_migrate"}, + ) + + # Should proceed to firmware install (same as normal thread flow) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "install_firmware" + assert result["step_id"] == "install_thread_firmware" + + +@pytest.mark.parametrize( + ("zha_source", "otbr_source", "expected_menu"), + [ + ( + SOURCE_USER, + SOURCE_USER, + ["pick_firmware_zigbee_migrate", "pick_firmware_thread_migrate"], + ), + ( + SOURCE_IGNORE, + SOURCE_USER, + ["pick_firmware_zigbee", "pick_firmware_thread_migrate"], + ), + ( + SOURCE_USER, + SOURCE_IGNORE, + ["pick_firmware_zigbee_migrate", "pick_firmware_thread"], + ), + ( + SOURCE_IGNORE, + SOURCE_IGNORE, + ["pick_firmware_zigbee", "pick_firmware_thread"], + ), + ], +) +async def test_config_flow_pick_firmware_with_ignored_entries( + hass: HomeAssistant, zha_source: str, otbr_source: str, expected_menu: str +) -> None: + """Test that ignored entries are properly excluded from migration menu options.""" + zha_entry = MockConfigEntry( + domain="zha", + data={"device": {"path": "/dev/ttyUSB1"}}, + title="ZHA", + source=zha_source, + ) + zha_entry.add_to_hass(hass) + + otbr_entry = MockConfigEntry( + domain="otbr", + data={"url": "http://192.168.1.100:8081"}, + title="OTBR", + source=otbr_source, + ) + otbr_entry.add_to_hass(hass) + + # Set up the flow + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + assert init_result["menu_options"] == expected_menu diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 0494de1432c..b8fd9e5cee8 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from aiohttp import ClientError import pytest -from homeassistant.components.hassio import AddonError, AddonInfo, AddonState +from homeassistant.components.hassio import AddonError from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -13,6 +13,7 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + OwningAddon, OwningIntegration, ) from homeassistant.core import HomeAssistant @@ -35,41 +36,6 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): """Mock supervisor client in tests.""" -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -@pytest.mark.parametrize( - "next_step", - [ - STEP_PICK_FIRMWARE_ZIGBEE, - STEP_PICK_FIRMWARE_THREAD, - ], -) -@pytest.mark.usefixtures("addon_store_info") -async def test_config_flow_cannot_probe_firmware( - next_step: str, hass: HomeAssistant -) -> None: - """Test failure case when firmware cannot be probed.""" - - with mock_firmware_info( - hass, - probe_app_type=None, - ): - # Start the flow - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": next_step}, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], @@ -80,19 +46,27 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: TEST_DOMAIN, context={"source": "hardware"} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, is_hassio=False, probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_thread_firmware" + + result = await consume_progress_flow( + hass, + flow_id=result["flow_id"], + valid_step_ids=("install_thread_firmware",), + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_hassio_thread" @@ -101,138 +75,112 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon cannot be installed.""" +async def test_config_flow_thread_addon_info_fails( + hass: HomeAssistant, + addon_store_info: AsyncMock, +) -> None: + """Test addon info fails before firmware install.""" + result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, _): - mock_otbr_manager.async_get_addon_info.side_effect = AddonError() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + flash_app_type=ApplicationType.SPINEL, + ): + addon_store_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_thread_firmware" + + result = await consume_progress_flow( + hass, + flow_id=result["flow_id"], + valid_step_ids=("install_thread_firmware",), + ) + # Cannot get addon info assert result["type"] == FlowResultType.ABORT assert result["reason"] == "addon_info_failed" +@pytest.mark.usefixtures("addon_not_installed") @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant) -> None: - """Test failure case when the Thread addon is already running.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_firmware_info( - hass, - probe_app_type=ApplicationType.EZSP, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={ - "device": TEST_DEVICE + "2", # A different device - }, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), - ) as (mock_otbr_manager, _): - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) - - # Cannot install addon - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "otbr_addon_already_running" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> None: +async def test_config_flow_thread_addon_install_fails( + hass: HomeAssistant, + install_addon: AsyncMock, +) -> None: """Test failure case when flasher addon cannot be installed.""" result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_firmware_info( - hass, - probe_app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, _): - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=AddonError() - ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + + with mock_firmware_info( + probe_app_type=ApplicationType.EZSP, + ): + install_addon.side_effect = AddonError() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_thread_firmware" + assert result["progress_action"] == "install_firmware" + + result = await consume_progress_flow( + hass, + flow_id=result["flow_id"], + valid_step_ids=( + "install_otbr_addon", + "install_thread_firmware", + ), + ) + # Cannot install addon assert result["type"] == FlowResultType.ABORT assert result["reason"] == "addon_install_failed" +@pytest.mark.usefixtures("addon_installed") @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: +async def test_config_flow_thread_addon_set_config_fails( + hass: HomeAssistant, + set_addon_options: AsyncMock, +) -> None: """Test failure case when flasher addon cannot be configured.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, _): - - async def install_addon() -> None: - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": TEST_DEVICE}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ) - - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=install_addon - ) - mock_otbr_manager.async_set_addon_options = AsyncMock(side_effect=AddonError()) - - confirm_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], user_input={} - ) + ): + set_addon_options.side_effect = AddonError() pick_thread_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) @@ -250,36 +198,29 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> assert pick_thread_progress_result["reason"] == "addon_set_config_failed" +@pytest.mark.usefixtures("addon_installed") @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: +async def test_config_flow_thread_flasher_run_fails( + hass: HomeAssistant, + start_addon: AsyncMock, +) -> None: """Test failure case when flasher addon fails to run.""" + start_addon.side_effect = AddonError() init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={"device": TEST_DEVICE}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ), - ) as (mock_otbr_manager, _): - mock_otbr_manager.async_start_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - confirm_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], user_input={} - ) + ): pick_thread_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) @@ -297,6 +238,7 @@ async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None assert pick_thread_progress_result["reason"] == "addon_start_failed" +@pytest.mark.usefixtures("addon_running") @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], @@ -307,24 +249,15 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non TEST_DOMAIN, context={"source": "hardware"} ) + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + with mock_firmware_info( - hass, probe_app_type=ApplicationType.EZSP, flash_app_type=None, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={"device": TEST_DEVICE}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), ): - confirm_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], user_input={} - ) pick_thread_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) @@ -353,13 +286,13 @@ async def test_config_flow_firmware_index_download_fails_and_required( TEST_DOMAIN, context={"source": "hardware"} ) - with ( - mock_firmware_info( - hass, - # The wrong firmware is installed, so a new install is required - probe_app_type=ApplicationType.SPINEL, - ) as (_, mock_update_client), - ): + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + with mock_firmware_info( + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as mock_update_client: mock_update_client.async_update_data.side_effect = ClientError() pick_result = await hass.config_entries.flow.async_configure( @@ -367,6 +300,14 @@ async def test_config_flow_firmware_index_download_fails_and_required( user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + assert pick_result["type"] is FlowResultType.ABORT assert pick_result["reason"] == "fw_download_failed" @@ -382,13 +323,13 @@ async def test_config_flow_firmware_download_fails_and_required( TEST_DOMAIN, context={"source": "hardware"} ) - with ( - mock_firmware_info( - hass, - # The wrong firmware is installed, so a new install is required - probe_app_type=ApplicationType.SPINEL, - ) as (_, mock_update_client), - ): + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + with mock_firmware_info( + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as mock_update_client: mock_update_client.async_fetch_firmware.side_effect = ClientError() pick_result = await hass.config_entries.flow.async_configure( @@ -396,6 +337,14 @@ async def test_config_flow_firmware_download_fails_and_required( user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + assert pick_result["type"] is FlowResultType.ABORT assert pick_result["reason"] == "fw_download_failed" @@ -452,7 +401,6 @@ async def test_options_flow_zigbee_to_thread_zha_configured( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, ) -> None: @@ -474,21 +422,23 @@ async def test_options_flow_thread_to_zigbee_otbr_configured( # Confirm options flow result = await hass.config_entries.options.async_init(config_entry.entry_id) - with mock_firmware_info( - hass, - probe_app_type=ApplicationType.SPINEL, - otbr_addon_info=AddonInfo( - available=True, - hostname=None, - options={"device": TEST_DEVICE}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), + # Pretend OTBR is using the stick + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[ + FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="1.2.3.4", + source="otbr", + owners=[OwningAddon(slug="openthread_border_router")], + ) + ], ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "otbr_still_using_stick" + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "otbr_still_using_stick" diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index 3103e5cfc6a..5f99d64c1b1 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Sequence import dataclasses import logging from unittest.mock import Mock, patch @@ -29,6 +29,7 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, OwningIntegration, + ResetTarget, ) from homeassistant.components.update import UpdateDeviceClass from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow @@ -197,7 +198,7 @@ async def mock_async_setup_update_entities( class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Mock SkyConnect firmware update entity.""" - bootloader_reset_type = None + bootloader_reset_methods = [] def __init__( self, @@ -361,7 +362,7 @@ async def test_update_entity_installation( 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: await asyncio.sleep(0) diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 048bf998d13..e9c20ffb8d6 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -580,7 +580,7 @@ async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: patch( "homeassistant.components.homeassistant_hardware.util.Flasher", return_value=mock_flasher, - ), + ) as flasher_mock, patch( "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" ), @@ -594,13 +594,17 @@ async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, - bootloader_reset_type=None, + bootloader_reset_methods=(), progress_callback=progress_callback, ) assert progress_callback.mock_calls == [call(0, 100), call(50, 100), call(100, 100)] assert after_flash_info == expected_firmware_info + # Verify Flasher was called with correct bootloader_reset parameter + assert flasher_mock.call_count == 1 + assert flasher_mock.mock_calls[0].kwargs["bootloader_reset"] == () + # Both owning integrations/addons are stopped and restarted assert owner1.temporarily_stop.mock_calls == [ call(hass), @@ -653,7 +657,7 @@ async def test_async_flash_silabs_firmware_flash_failure(hass: HomeAssistant) -> device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, - bootloader_reset_type=None, + bootloader_reset_methods=(), ) # Both owning integrations/addons are stopped and restarted @@ -713,7 +717,7 @@ async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, - bootloader_reset_type=None, + bootloader_reset_methods=(), ) # Both owning integrations/addons are stopped and restarted diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 89ec292d879..e71a86384c1 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -27,7 +27,7 @@ def mock_zha(): with ( patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index bdde5e09ea6..01478900c60 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Home Assistant SkyConnect config flow.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, call, patch import pytest @@ -29,41 +30,57 @@ from .common import USB_DATA_SKY, USB_DATA_ZBT1 from tests.common import MockConfigEntry +@pytest.fixture(name="supervisor") +def mock_supervisor_fixture() -> Generator[None]: + """Mock Supervisor.""" + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", + return_value=True, + ): + yield + + +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_sky_connect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.mark.parametrize( - ("step", "usb_data", "model", "fw_type", "fw_version"), + ("usb_data", "model"), [ ( - STEP_PICK_FIRMWARE_ZIGBEE, USB_DATA_SKY, "Home Assistant SkyConnect", - ApplicationType.EZSP, - "7.4.4.0 build 0", ), ( - STEP_PICK_FIRMWARE_THREAD, USB_DATA_ZBT1, "Home Assistant Connect ZBT-1", - ApplicationType.SPINEL, - "2.4.4.0", ), ], ) -async def test_config_flow( - step: str, +async def test_config_flow_zigbee( usb_data: UsbServiceInfo, model: str, - fw_type: ApplicationType, - fw_version: str, hass: HomeAssistant, ) -> None: - """Test the config flow for SkyConnect.""" + """Test the config flow for SkyConnect with Zigbee.""" + fw_type = ApplicationType.EZSP + fw_version = "7.4.4.0 build 0" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "usb"}, data=usb_data ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["model"] == model + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["model"] == model async def mock_install_firmware_step( self, @@ -74,44 +91,33 @@ async def test_config_flow( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) + return await getattr(self, f"async_step_{next_step_id}")() with ( - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", - return_value=None, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): - confirm_result = await hass.config_entries.flow.async_configure( + pick_result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": step}, + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == ( - "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" - ) + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" create_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], user_input={} + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, ) assert create_result["type"] is FlowResultType.CREATE_ENTRY @@ -130,15 +136,107 @@ async def test_config_flow( flows = hass.config_entries.flow.async_progress() - if step == STEP_PICK_FIRMWARE_ZIGBEE: - # Ensure a ZHA discovery flow has been created - assert len(flows) == 1 - zha_flow = flows[0] - assert zha_flow["handler"] == "zha" - assert zha_flow["context"]["source"] == "hardware" - assert zha_flow["step_id"] == "confirm" - else: - assert len(flows) == 0 + # Ensure a ZHA discovery flow has been created + assert len(flows) == 1 + zha_flow = flows[0] + assert zha_flow["handler"] == "zha" + assert zha_flow["context"]["source"] == "hardware" + assert zha_flow["step_id"] == "confirm" + + +@pytest.mark.usefixtures("addon_installed", "supervisor") +@pytest.mark.parametrize( + ("usb_data", "model"), + [ + ( + USB_DATA_SKY, + "Home Assistant SkyConnect", + ), + ( + USB_DATA_ZBT1, + "Home Assistant Connect ZBT-1", + ), + ], +) +async def test_config_flow_thread( + usb_data: UsbServiceInfo, + model: str, + hass: HomeAssistant, + start_addon: AsyncMock, +) -> None: + """Test the config flow for SkyConnect with Thread.""" + fw_type = ApplicationType.SPINEL + fw_version = "2.4.4.0" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=usb_data + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["model"] == model + + async def mock_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: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) + return await getattr(self, f"async_step_{next_step_id}")() + + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", + autospec=True, + side_effect=mock_install_firmware_step, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_otbr_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + create_result = await hass.config_entries.flow.async_configure( + result["flow_id"] + ) + + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": fw_type.value, + "firmware_version": fw_version, + "device": usb_data.device, + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "description": usb_data.description, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + } + + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 0 @pytest.mark.parametrize( @@ -175,39 +273,51 @@ async def test_options_flow( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "spinel" - assert result["description_placeholders"]["model"] == model + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "spinel" + assert description_placeholders["model"] == model - async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_pre_confirm_zigbee() + async def mock_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: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ) + return await getattr(self, f"async_step_{next_step_id}")() with ( patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", - autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[], ), patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", - owners=[], - source="probe", - ), + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow._install_firmware_step", + autospec=True, + side_effect=mock_install_firmware_step, ), ): - confirm_result = await hass.config_entries.options.async_configure( + pick_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == "confirm_zigbee" + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" create_result = await hass.config_entries.options.async_configure( - confirm_result["flow_id"], user_input={} + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, ) assert create_result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index 7247c7da4e2..ef89f5ba330 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -27,7 +27,7 @@ def mock_zha_config_flow_setup() -> Generator[None]: side_effect=mock_probe, ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 6e2120aa961..0cb1b2ab3f4 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Home Assistant Yellow config flow.""" from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, call, patch import pytest @@ -76,6 +76,16 @@ def mock_reboot_host(supervisor_client: AsyncMock) -> AsyncMock: return supervisor_client.host.reboot +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_yellow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) @@ -307,18 +317,11 @@ async def test_option_flow_led_settings_fail_2( assert result["reason"] == "write_hw_settings_error" -@pytest.mark.parametrize( - ("step", "fw_type", "fw_version"), - [ - (STEP_PICK_FIRMWARE_ZIGBEE, ApplicationType.EZSP, "7.4.4.0 build 0"), - (STEP_PICK_FIRMWARE_THREAD, ApplicationType.SPINEL, "2.4.4.0"), - ], -) @pytest.mark.usefixtures("addon_store_info") -async def test_firmware_options_flow( - step: str, fw_type: ApplicationType, fw_version: str, hass: HomeAssistant -) -> None: +async def test_firmware_options_flow_zigbee(hass: HomeAssistant) -> None: """Test the firmware options flow for Yellow.""" + fw_type = ApplicationType.EZSP + fw_version = "7.4.4.0 build 0" mock_integration(hass, MockModule("hassio")) await async_setup_component(hass, HASSIO_DOMAIN, {}) @@ -345,11 +348,130 @@ async def test_firmware_options_flow( ) assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "spinel" - assert result["description_placeholders"]["model"] == "Home Assistant Yellow" + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "spinel" + assert description_placeholders["model"] == "Home Assistant Yellow" - async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_pre_confirm_zigbee() + mock_update_client = AsyncMock() + mock_manifest = Mock() + mock_firmware = Mock() + mock_firmware.filename = "yellow_zigbee_ncp_7.4.4.0.gbl" + mock_firmware.metadata = { + "ezsp_version": "7.4.4.0", + "fw_type": "yellow_zigbee_ncp", + "metadata_version": 2, + } + mock_manifest.firmwares = [mock_firmware] + mock_update_client.async_update_data.return_value = mock_manifest + mock_update_client.async_fetch_firmware.return_value = b"firmware_data" + + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[], + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.FirmwareUpdateClient", + return_value=mock_update_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware", + return_value=FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=fw_type, + firmware_version=fw_version, + owners=[], + source="probe", + ), + ) as flash_mock, + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + side_effect=[ + # First call: probe before installation (returns current SPINEL firmware) + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.SPINEL, + firmware_version="2.4.4.0", + owners=[], + source="probe", + ), + # Second call: probe after installation (returns new EZSP firmware) + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=fw_type, + firmware_version=fw_version, + owners=[], + source="probe", + ), + ], + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + ): + pick_result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + create_result = await hass.config_entries.options.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_recommended"}, + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + assert config_entry.data == { + "firmware": fw_type.value, + "firmware_version": fw_version, + } + + # Verify async_flash_silabs_firmware was called with Yellow's reset method + assert flash_mock.call_count == 1 + assert flash_mock.mock_calls[0].kwargs["bootloader_reset_methods"] == ["yellow"] + + +@pytest.mark.usefixtures("addon_installed") +async def test_firmware_options_flow_thread( + hass: HomeAssistant, start_addon: AsyncMock +) -> None: + """Test the firmware options flow for Yellow with Thread.""" + fw_type = ApplicationType.SPINEL + fw_version = "2.4.4.0" + mock_integration(hass, MockModule("hassio")) + await async_setup_component(hass, HASSIO_DOMAIN, {}) + + config_entry = MockConfigEntry( + data={"firmware": ApplicationType.SPINEL}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + version=1, + minor_version=2, + ) + config_entry.add_to_hass(hass) + + # First step is confirmation + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "main_menu" + assert "firmware_settings" in result["menu_options"] + + # Pick firmware settings + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": "firmware_settings"}, + ) + + assert result["step_id"] == "pick_firmware" + description_placeholders = result["description_placeholders"] + assert description_placeholders is not None + assert description_placeholders["firmware_type"] == "spinel" + assert description_placeholders["model"] == "Home Assistant Yellow" async def mock_install_firmware_step( self, @@ -360,53 +482,44 @@ async def test_firmware_options_flow( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - if next_step_id == "start_otbr_addon": - next_step_id = "pre_confirm_otbr" - - return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + self._probed_firmware_info = FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) + return await getattr(self, f"async_step_{next_step_id}")() with ( patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", - autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, - ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._ensure_thread_addon_setup", - return_value=None, + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[], ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._install_firmware_step", autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=RADIO_DEVICE, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): - confirm_result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"next_step_id": step}, + user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == ( - "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" - ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_otbr_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() create_result = await hass.config_entries.options.async_configure( - confirm_result["flow_id"], user_input={} + result["flow_id"] ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert create_result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.data == { "firmware": fw_type.value, "firmware_version": fw_version, diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 00e3383cf77..7bff7f10c65 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -71,10 +71,16 @@ async def test_setup_entry( if num_entries > 0: zha_flows = hass.config_entries.flow.async_progress_by_handler("zha") assert len(zha_flows) == 1 - assert zha_flows[0]["step_id"] == "choose_formation_strategy" + assert zha_flows[0]["step_id"] == "choose_setup_strategy" + + setup_result = await hass.config_entries.flow.async_configure( + zha_flows[0]["flow_id"], + user_input={"next_step_id": zha.config_flow.SETUP_STRATEGY_ADVANCED}, + ) + assert setup_result["step_id"] == "choose_formation_strategy" await hass.config_entries.flow.async_configure( - zha_flows[0]["flow_id"], + setup_result["flow_id"], user_input={"next_step_id": zha.config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() @@ -117,10 +123,16 @@ async def test_setup_zha(hass: HomeAssistant, addon_store_info) -> None: # Finish setting up ZHA zha_flows = hass.config_entries.flow.async_progress_by_handler("zha") assert len(zha_flows) == 1 - assert zha_flows[0]["step_id"] == "choose_formation_strategy" + assert zha_flows[0]["step_id"] == "choose_setup_strategy" + + setup_result = await hass.config_entries.flow.async_configure( + zha_flows[0]["flow_id"], + user_input={"next_step_id": zha.config_flow.SETUP_STRATEGY_ADVANCED}, + ) + assert setup_result["step_id"] == "choose_formation_strategy" await hass.config_entries.flow.async_configure( - zha_flows[0]["flow_id"], + setup_result["flow_id"], user_input={"next_step_id": zha.config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() diff --git a/tests/components/homee/fixtures/add_device.json b/tests/components/homee/fixtures/add_device.json new file mode 100644 index 00000000000..e0876c30732 --- /dev/null +++ b/tests/components/homee/fixtures/add_device.json @@ -0,0 +1,176 @@ +{ + "id": 3, + "name": "Added Device", + "profile": 4010, + "image": "default", + "favorite": 0, + "order": 20, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1709379826, + "added": 1676199446, + "history": 1, + "cube_type": 1, + "note": "", + "services": 5, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 21, + "node_id": 3, + "instance": 1, + "minimum": 0, + "maximum": 200000, + "current_value": 555.591, + "target_value": 555.591, + "last_value": 555.586, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 4, + "state": 1, + "last_changed": 1694175270, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 22, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 17, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 27, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 0.5, + "editable": 1, + "type": 349, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 28, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 346, + "state": 1, + "last_changed": 1624806728, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 29, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 13, + "state": 1, + "last_changed": 1736003985, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 30, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_binary_sensor.ambr b/tests/components/homee/snapshots/test_binary_sensor.ambr index 0e9f02edf6c..f9f905bfc44 100644 --- a/tests/components/homee/snapshots/test_binary_sensor.ambr +++ b/tests/components/homee/snapshots/test_binary_sensor.ambr @@ -1,4 +1,1473 @@ # serializer version: 1 +# name: test_add_device[binary_sensor.added_device_blackout-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.added_device_blackout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blackout', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blackout_alarm', + 'unique_id': '00055511EECC-3-22', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.added_device_blackout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Added Device Blackout', + }), + 'context': , + 'entity_id': 'binary_sensor.added_device_blackout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_battery-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.test_binary_sensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Binary Sensor Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_blackout-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.test_binary_sensor_blackout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blackout', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blackout_alarm', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_blackout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Blackout', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_blackout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_carbon_dioxide-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_dioxide', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Carbon dioxide', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_carbon_monoxide-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Test Binary Sensor Carbon monoxide', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_flood-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_flood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flood', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flood', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_flood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Flood', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_flood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_high_temperature-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.test_binary_sensor_high_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_temperature', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_high_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Binary Sensor High temperature', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_high_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_leak-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_leak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Leak', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'leak_alarm', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_leak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Leak', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_leak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_load-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.test_binary_sensor_load', + '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': 'Load', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_alarm', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Binary Sensor Load', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'Test Binary Sensor Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_low_temperature-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.test_binary_sensor_low_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_temperature', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_low_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'cold', + 'friendly_name': 'Test Binary Sensor Low temperature', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_low_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_malfunction-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.test_binary_sensor_malfunction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Malfunction', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'malfunction', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_malfunction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Malfunction', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_malfunction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_maximum_level-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.test_binary_sensor_maximum_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum level', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_maximum_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Maximum level', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_maximum_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_minimum_level-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.test_binary_sensor_minimum_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Minimum level', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'minimum', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_minimum_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Minimum level', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_minimum_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_motion-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Test Binary Sensor Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_motor_blocked-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.test_binary_sensor_motor_blocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motor blocked', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motor_blocked', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_motor_blocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Motor blocked', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_motor_blocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_opening-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_opening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Opening', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'opening', + 'unique_id': '00055511EECC-1-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Test Binary Sensor Opening', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_overcurrent-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.test_binary_sensor_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overcurrent', + 'unique_id': '00055511EECC-1-18', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_overload-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.test_binary_sensor_overload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overload', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overload', + 'unique_id': '00055511EECC-1-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_overload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Overload', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_overload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_plug-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plug', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'Test Binary Sensor Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_power-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.test_binary_sensor_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00055511EECC-1-21', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Binary Sensor Power', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_presence-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_presence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Presence', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'presence', + 'unique_id': '00055511EECC-1-20', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test Binary Sensor Presence', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_rain-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rain', + 'unique_id': '00055511EECC-1-22', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Rain', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_replace_filter-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.test_binary_sensor_replace_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Replace filter', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'replace_filter', + 'unique_id': '00055511EECC-1-23', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_replace_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Replace filter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_replace_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_smoke-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': None, + 'entity_id': 'binary_sensor.test_binary_sensor_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smoke', + 'unique_id': '00055511EECC-1-24', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Test Binary Sensor Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_storage-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.test_binary_sensor_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage', + 'unique_id': '00055511EECC-1-25', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Storage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_surge-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.test_binary_sensor_surge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Surge', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'surge', + 'unique_id': '00055511EECC-1-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_surge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Surge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_surge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_tamper-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.test_binary_sensor_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tamper', + 'unique_id': '00055511EECC-1-27', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Test Binary Sensor Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_voltage_drop-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.test_binary_sensor_voltage_drop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage drop', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_drop', + 'unique_id': '00055511EECC-1-28', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_voltage_drop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Voltage drop', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_voltage_drop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_water-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.test_binary_sensor_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water', + 'unique_id': '00055511EECC-1-29', + 'unit_of_measurement': None, + }) +# --- +# name: test_add_device[binary_sensor.test_binary_sensor_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Water', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_sensor_snapshot[binary_sensor.test_binary_sensor_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_binary_sensor.py b/tests/components/homee/test_binary_sensor.py index 50662616379..ef3cf8ecee3 100644 --- a/tests/components/homee/test_binary_sensor.py +++ b/tests/components/homee/test_binary_sensor.py @@ -27,3 +27,26 @@ async def test_sensor_snapshot( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_add_device( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test adding a device.""" + mock_homee.nodes = [build_mock_node("binary_sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + # Add a new device + added_node = build_mock_node("add_device.json") + mock_homee.nodes.append(added_node) + mock_homee.get_node_by_id.return_value = mock_homee.nodes[1] + await mock_homee.add_nodes_listener.call_args_list[0][0][0](added_node, True) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 47a9c398d16..da84b21fbb2 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -884,3 +884,224 @@ async def test_valve_with_duration_characteristics( await hass.async_block_till_done() assert acc.get_duration() == 900 assert acc.get_remaining_duration() == 600 + + +async def test_duration_characteristic_properties( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test SetDuration and RemainingDuration characteristic properties from linked entity attributes.""" + entity_id = "switch.sprinkler" + linked_duration_entity = "input_number.valve_duration" + linked_end_time_entity = "sensor.valve_end_time" + + # Case 1: linked input_number has min, max, step attributes + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set( + linked_duration_entity, + "120", + { + "min": 10, + "max": 900, + "step": 5, + }, + ) + hass.states.async_set(linked_end_time_entity, dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + { + "type": "sprinkler", + "linked_valve_duration": linked_duration_entity, + "linked_valve_end_time": linked_end_time_entity, + }, + ) + acc.run() + await hass.async_block_till_done() + + set_duration_props = acc.char_set_duration.properties + assert set_duration_props["minValue"] == 10 + assert set_duration_props["maxValue"] == 900 + assert set_duration_props["minStep"] == 5 + + remaining_duration_props = acc.char_remaining_duration.properties + assert remaining_duration_props["minValue"] == 0 + assert remaining_duration_props["maxValue"] == 900 + assert remaining_duration_props["minStep"] == 1 + + # Case 2: linked input_number missing attributes, should use defaults + hass.states.async_set( + linked_duration_entity, + "60", + {}, # No min, max, step + ) + await hass.async_block_till_done() + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 6, + { + "type": "sprinkler", + "linked_valve_duration": linked_duration_entity, + "linked_valve_end_time": linked_end_time_entity, + }, + ) + acc.run() + await hass.async_block_till_done() + + set_duration_props = acc.char_set_duration.properties + assert set_duration_props["minValue"] == 0 + assert set_duration_props["maxValue"] == 3600 + assert set_duration_props["minStep"] == 1 + + remaining_duration_props = acc.char_remaining_duration.properties + assert remaining_duration_props["minValue"] == 0 + assert remaining_duration_props["maxValue"] == 60 * 60 * 48 + assert remaining_duration_props["minStep"] == 1 + + # Case 4: linked input_number missing attribute value, should use defaults + hass.states.async_set( + linked_duration_entity, + "60", + { + "min": 900, + "max": None, # No value + }, + ) + await hass.async_block_till_done() + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 6, + { + "type": "sprinkler", + "linked_valve_duration": linked_duration_entity, + "linked_valve_end_time": linked_end_time_entity, + }, + ) + acc.run() + await hass.async_block_till_done() + + set_duration_props = acc.char_set_duration.properties + assert set_duration_props["minValue"] == 900 + assert set_duration_props["maxValue"] == 3600 + assert set_duration_props["minStep"] == 1 + + remaining_duration_props = acc.char_remaining_duration.properties + assert remaining_duration_props["minValue"] == 0 + assert remaining_duration_props["maxValue"] == 60 * 60 * 48 + assert remaining_duration_props["minStep"] == 1 + + # Case 3: linked input_number missing state, should use defaults + hass.states.async_remove(linked_duration_entity) + await hass.async_block_till_done() + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 7, + { + "type": "sprinkler", + "linked_valve_duration": linked_duration_entity, + "linked_valve_end_time": linked_end_time_entity, + }, + ) + acc.run() + await hass.async_block_till_done() + + set_duration_props = acc.char_set_duration.properties + assert set_duration_props["minValue"] == 0 + assert set_duration_props["maxValue"] == 3600 + assert set_duration_props["minStep"] == 1 + + remaining_duration_props = acc.char_remaining_duration.properties + assert remaining_duration_props["minValue"] == 0 + assert remaining_duration_props["maxValue"] == 60 * 60 * 48 + assert remaining_duration_props["minStep"] == 1 + + # Case 5: Attribute is not valid + assert acc._get_linked_duration_property("invalid_property", 1000) == 1000 + + +async def test_remaining_duration_characteristic_fallback( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test remaining duration falls back to default run time only if valve is active.""" + entity_id = "switch.sprinkler" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "900") + hass.states.async_set("sensor.valve_end_time", None) + await hass.async_block_till_done() + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + { + "type": "sprinkler", + "linked_valve_duration": "input_number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + ) + acc.run() + await hass.async_block_till_done() + + # Case 1: Remaining duration should always be 0 when accessory is not in use + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_in_use.value == 0 + assert acc.get_remaining_duration() == 0 + + # Case 2: Remaining duration should fall back to default duration when accessory is in use + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_in_use.value == 1 + assert acc.get_remaining_duration() == 900 + + # Case 3: Remaining duration calculated from linked end time if state is available + with freeze_time(dt_util.utcnow()): + # End time is in the futue and valve is in use + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=3600)).isoformat(), + ) + await hass.async_block_till_done() + assert acc.char_in_use.value == 1 + assert acc.get_remaining_duration() == 3600 + + # End time is in the futue and valve is not in use + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_in_use.value == 0 + assert acc.get_remaining_duration() == 3600 + + # End time is in the past and valve is in use, returning 0 + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() - timedelta(seconds=3600)).isoformat(), + ) + await hass.async_block_till_done() + assert acc.char_in_use.value == 1 + assert acc.get_remaining_duration() == 0 + + # End time is in the past and valve is not in use, returning 0 + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_in_use.value == 0 + assert acc.get_remaining_duration() == 0 diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 95d24957fcb..ea9c638c022 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -900,7 +900,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery-20', 'original_name': 'eufyCam2-0000 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -1156,7 +1156,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery-40', 'original_name': 'eufyCam2-000A Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -1412,7 +1412,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery-alert', 'original_name': 'eufyCam2-000A Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -1848,7 +1848,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Contact Sensor Battery Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -2270,7 +2270,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Programmable Switch Battery Sensor', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -2601,7 +2601,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery-80', 'original_name': 'ArloBabyA0 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -4473,7 +4473,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Basement Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -4780,7 +4780,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Basement Window 1 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -5037,7 +5037,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Deck Door Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -5294,7 +5294,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Front Door Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -5551,7 +5551,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Garage Door Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -5765,7 +5765,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Living Room Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -6072,7 +6072,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Living Room Window 1 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -6329,7 +6329,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Loft window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -6543,7 +6543,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Master BR Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -6850,7 +6850,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Master BR Window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -7462,7 +7462,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Upstairs BR Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -7769,7 +7769,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Upstairs BR Window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -10111,7 +10111,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery-60', 'original_name': 'Eve Degree AA11 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -11099,7 +11099,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Family Room North Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -11351,7 +11351,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Kitchen Window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -12392,7 +12392,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Laundry Smoke ED78 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -12568,7 +12568,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Family Room North Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -12820,7 +12820,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Kitchen Window Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -14645,7 +14645,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Laundry Smoke ED78 Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -16083,7 +16083,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Hue dimmer switch battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -20820,7 +20820,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'Master Bath South RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -21072,7 +21072,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'RYSE SmartShade RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -21248,7 +21248,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'BR Left RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -21420,7 +21420,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery-90', 'original_name': 'LR Left RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -21592,7 +21592,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery', 'original_name': 'LR Right RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, @@ -21844,7 +21844,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:battery-unknown', + 'original_icon': 'mdi:battery-alert', 'original_name': 'RZSS RYSE Shade Battery', 'platform': 'homekit_controller', 'previous_unique_id': None, diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index b3190c510fd..82c8a1b8102 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -17,7 +17,7 @@ from ..common import ( async def test_connectsense_setup(hass: HomeAssistant) -> None: """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "connectsense.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + config_entry, _pairing = await setup_test_accessories(hass, accessories) await assert_devices_and_entities_created( hass, diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 059993e3bef..e79d3ab3edb 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -190,7 +190,7 @@ async def test_ecobee3_setup_connection_failure( # If there is no cached entity map and the accessory connection is # failing then we have to fail the config entry setup. - config_entry, pairing = await setup_test_accessories(hass, accessories) + config_entry, _pairing = await setup_test_accessories(hass, accessories) assert config_entry.state is ConfigEntryState.SETUP_RETRY climate = entity_registry.async_get("climate.homew") diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 00c7bb16259..cf134010517 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -2,15 +2,20 @@ from collections.abc import Callable import dataclasses +from typing import Any from unittest import mock from aiohomekit.controller import TransportType -from aiohomekit.model import Accessory +from aiohomekit.model import Accessories, Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes from aiohomekit.testing import FakeController import pytest +from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE +from homeassistant.components.homekit_controller.connection import ( + MAX_CHARACTERISTICS_PER_REQUEST, +) from homeassistant.components.homekit_controller.const import ( DEBOUNCE_COOLDOWN, DOMAIN, @@ -375,9 +380,15 @@ async def test_poll_firmware_version_only_all_watchable_accessory_mode( state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify everything is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} + # Verify everything is polled (convert to set for comparison since batching changes the type) + assert set(mock_get_characteristics.call_args_list[0][0][0]) == { + (1, 10), + (1, 11), + } + assert set(mock_get_characteristics.call_args_list[1][0][0]) == { + (1, 10), + (1, 11), + } # Test device goes offline helper.pairing.available = False @@ -439,3 +450,223 @@ async def test_manual_poll_all_chars( await time_changed(hass, DEBOUNCE_COOLDOWN) await hass.async_block_till_done() assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1 + + +async def test_poll_all_on_startup_refreshes_stale_values( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test that entities get fresh values on startup instead of stale stored values.""" + # Load actual Ecobee accessory fixture + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + + # Pre-populate storage with the accessories data (already has stale values) + hass_storage["homekit_controller-entity-map"] = { + "version": 1, + "minor_version": 1, + "key": "homekit_controller-entity-map", + "data": { + "pairings": { + "00:00:00:00:00:00": { + "config_num": 1, + "accessories": [ + a.to_accessory_and_service_list() for a in accessories + ], + } + } + }, + } + + # Track what gets polled during setup + polled_chars: list[tuple[int, int]] = [] + + # Set up the test accessories + fake_controller = await setup_platform(hass) + + # Mock get_characteristics to track polling and return fresh temperature + async def mock_get_characteristics( + chars: set[tuple[int, int]], **kwargs: Any + ) -> dict[tuple[int, int], dict[str, Any]]: + """Return fresh temperature value when polled.""" + polled_chars.extend(chars) + # Return fresh values for all characteristics + result: dict[tuple[int, int], dict[str, Any]] = {} + for aid, iid in chars: + # Find the characteristic and return appropriate value + for accessory in accessories: + if accessory.aid != aid: + continue + for service in accessory.services: + for char in service.characteristics: + if char.iid != iid: + continue + # Return fresh temperature instead of stale fixture value + if char.type == CharacteristicsTypes.TEMPERATURE_CURRENT: + result[(aid, iid)] = {"value": 22.5} # Fresh value + else: + result[(aid, iid)] = {"value": char.value} + break + return result + + # Add the paired device with our mock + await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") + config_entry = MockConfigEntry( + version=1, + domain="homekit_controller", + entry_id="TestData", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + title="test", + ) + config_entry.add_to_hass(hass) + + # Get the pairing and patch its get_characteristics + pairing = fake_controller.pairings["00:00:00:00:00:00"] + + with mock.patch.object(pairing, "get_characteristics", mock_get_characteristics): + # Set up the config entry (this should trigger poll_all=True) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify that polling happened during setup (poll_all=True was used) + assert ( + len(polled_chars) == 79 + ) # The Ecobee fixture has exactly 79 readable characteristics + + # Check that the climate entity has the fresh temperature (22.5°C) not the stale fixture value (21.8°C) + state = hass.states.get("climate.homew") + assert state is not None + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5 + + +async def test_characteristic_polling_batching( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that characteristic polling is batched to MAX_CHARACTERISTICS_PER_REQUEST.""" + + # Create a large accessory with many characteristics (more than 49) + def create_large_accessory_with_many_chars(accessory: Accessory) -> None: + """Create an accessory with many characteristics to test batching.""" + # Add multiple services with many characteristics each + for service_num in range(10): # 10 services + service = accessory.add_service( + ServicesTypes.LIGHTBULB, name=f"Light {service_num}" + ) + # Each lightbulb service gets several characteristics + service.add_char(CharacteristicsTypes.ON) + service.add_char(CharacteristicsTypes.BRIGHTNESS) + service.add_char(CharacteristicsTypes.HUE) + service.add_char(CharacteristicsTypes.SATURATION) + service.add_char(CharacteristicsTypes.COLOR_TEMPERATURE) + # Set initial values + for char in service.characteristics: + if char.type != CharacteristicsTypes.IDENTIFY: + char.value = 0 + + helper = await setup_test_component( + hass, get_next_aid(), create_large_accessory_with_many_chars + ) + + # Track the get_characteristics calls + get_chars_calls = [] + original_get_chars = helper.pairing.get_characteristics + + async def mock_get_characteristics(chars): + """Mock get_characteristics to track batch sizes.""" + get_chars_calls.append(list(chars)) + return await original_get_chars(chars) + + # Clear any calls from setup + get_chars_calls.clear() + + # Patch get_characteristics to track calls + with mock.patch.object( + helper.pairing, "get_characteristics", side_effect=mock_get_characteristics + ): + # Trigger an update through time_changed which simulates regular polling + # time_changed expects seconds, not a datetime + await time_changed(hass, 300) # 5 minutes in seconds + await hass.async_block_till_done() + + # We created 10 lightbulb services with 5 characteristics each = 50 total + # Plus any base accessory characteristics that are pollable + # This should result in exactly 2 batches + assert len(get_chars_calls) == 2, ( + f"Should have made exactly 2 batched calls, got {len(get_chars_calls)}" + ) + + # Check that no batch exceeded MAX_CHARACTERISTICS_PER_REQUEST + for i, batch in enumerate(get_chars_calls): + assert len(batch) <= MAX_CHARACTERISTICS_PER_REQUEST, ( + f"Batch {i} size {len(batch)} exceeded maximum {MAX_CHARACTERISTICS_PER_REQUEST}" + ) + + # Verify the total number of characteristics polled + total_chars = sum(len(batch) for batch in get_chars_calls) + # Each lightbulb has: ON, BRIGHTNESS, HUE, SATURATION, COLOR_TEMPERATURE = 5 + # 10 lightbulbs = 50 characteristics + assert total_chars == 50, ( + f"Should have polled exactly 50 characteristics, got {total_chars}" + ) + + # The first batch should be full (49 characteristics) + assert len(get_chars_calls[0]) == 49, ( + f"First batch should have exactly 49 characteristics, got {len(get_chars_calls[0])}" + ) + + # The second batch should have exactly 1 characteristic + assert len(get_chars_calls[1]) == 1, ( + f"Second batch should have exactly 1 characteristic, got {len(get_chars_calls[1])}" + ) + + +async def test_async_setup_handles_unparsable_response( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that async_setup handles ValueError from unparsable accessory responses.""" + accessories = Accessories() + accessory = Accessory.create_with_info( + 1, "TestDevice", "example.com", "Test", "0001", "0.1" + ) + service = accessory.add_service(ServicesTypes.LIGHTBULB) + on_char = service.add_char(CharacteristicsTypes.ON) + on_char.value = False + accessories.add_accessory(accessory) + + async def mock_get_characteristics( + chars: set[tuple[int, int]], **kwargs: Any + ) -> dict[tuple[int, int], dict[str, Any]]: + """Mock that raises ValueError to simulate unparsable response.""" + raise ValueError( + "Unable to parse text", + ("Error processing token: filename. Filename missing or too long?"), + ) + + fake_controller = await setup_platform(hass) + await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") + + config_entry = MockConfigEntry( + version=1, + domain="homekit_controller", + entry_id="TestData", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + title="test", + ) + config_entry.add_to_hass(hass) + + pairing = fake_controller.pairings["00:00:00:00:00:00"] + + with ( + caplog.at_level("DEBUG", logger="homeassistant.components.homekit_controller"), + mock.patch.object(pairing, "get_characteristics", mock_get_characteristics), + ): + # Set up the config entry - this will trigger async_setup + # with poll_all=True + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "responded with unparsable response, first update was skipped" in caplog.text + assert "Error processing token: filename" in caplog.text + + # Verify that setup completed - entities were still created + # despite the polling error. The light entity should exist even + # though initial polling failed + state = hass.states.get("light.testdevice") + assert state is not None diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 86c428b4413..166fd1a9e65 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -330,7 +330,6 @@ async def test_snapshots( 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") devices.append({"device": device_dict, "entities": entities}) diff --git a/tests/components/homekit_controller/test_storage.py b/tests/components/homekit_controller/test_storage.py index 97856c2c784..868a18af1f9 100644 --- a/tests/components/homekit_controller/test_storage.py +++ b/tests/components/homekit_controller/test_storage.py @@ -88,3 +88,32 @@ async def test_storage_is_updated_on_add( # Is saved out to store? await flush_store(entity_map.store) assert hkid in hass_storage[ENTITY_MAP]["data"]["pairings"] + + +async def test_storage_is_saved_on_stop( + hass: HomeAssistant, hass_storage: dict[str, Any], get_next_aid: Callable[[], int] +) -> None: + """Test entity map storage is saved when Home Assistant stops.""" + await setup_test_component(hass, get_next_aid(), create_lightbulb_service) + + entity_map: EntityMapStorage = hass.data[ENTITY_MAP] + hkid = "00:00:00:00:00:00" + + # Verify the device is in memory + assert hkid in entity_map.storage_data + + # Clear the storage to verify it gets saved on stop + del hass_storage[ENTITY_MAP] + + # Make a change to trigger a save + entity_map.async_create_or_update_map(hkid, 2, []) # Update config_num + + # Simulate Home Assistant stopping (sets the state and fires the event) + await hass.async_stop() + await hass.async_block_till_done() + + # Verify the storage was saved + assert ENTITY_MAP in hass_storage + assert hkid in hass_storage[ENTITY_MAP]["data"]["pairings"] + # Verify the updated data was saved + assert hass_storage[ENTITY_MAP]["data"]["pairings"][hkid]["config_num"] == 2 diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index 4f6913cc8e8..90af26bee55 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -38,7 +38,7 @@ async def test_hmip_home_cloud_connection_sensor( test_devices=[entity_name] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index 67dbb55bb12..4f0283daa68 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -490,7 +490,7 @@ async def test_hmip_heating_profile_name_not_in_list( test_devices=["Heizkörperthermostat2"], test_groups=[entity_name], ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 8bff1798255..8c9ffc7dfd4 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -280,7 +280,7 @@ async def test_hmip_multi_area_device( test_devices=["Wired Eingangsmodul – 32-fach"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) assert ha_state diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index 85106f2d987..be432eaae31 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -241,7 +241,7 @@ async def test_hmip_notification_light_2_turn_off( device_model = "HmIP-BSL" mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_devices=["BSL2"]) - ha_state, hmip_device = get_and_check_entity_basics( + _ha_state, hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 669cbbf664f..825f3ab042d 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -565,7 +565,7 @@ async def test_hmip_esi_iec_current_power_consumption( test_devices=["esi_iec"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -583,7 +583,7 @@ async def test_hmip_esi_iec_energy_counter_usage_high_tariff( test_devices=["esi_iec"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -601,7 +601,7 @@ async def test_hmip_esi_iec_energy_counter_usage_low_tariff( test_devices=["esi_iec"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -619,7 +619,7 @@ async def test_hmip_esi_iec_energy_counter_input_single_tariff( test_devices=["esi_iec"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -652,7 +652,7 @@ async def test_hmip_esi_gas_current_gas_flow( test_devices=["esi_gas"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -670,7 +670,7 @@ async def test_hmip_esi_gas_gas_volume( test_devices=["esi_gas"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -688,7 +688,7 @@ async def test_hmip_esi_led_current_power_consumption( test_devices=["esi_led"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -706,7 +706,7 @@ async def test_hmip_esi_led_energy_counter_usage_high_tariff( test_devices=["esi_led"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -754,7 +754,7 @@ async def test_hmip_tilt_vibration_sensor_tilt_angle( test_devices=["Neigungssensor Tor"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -772,7 +772,7 @@ async def test_hmip_absolute_humidity_sensor( test_devices=["elvshctv"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -811,7 +811,7 @@ async def test_hmip_water_valve_current_water_flow( test_devices=["Bewaesserungsaktor"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -834,7 +834,7 @@ async def test_hmip_water_valve_water_volume( test_devices=["Bewaesserungsaktor"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -854,7 +854,7 @@ async def test_hmip_water_valve_water_volume_since_open( test_devices=["Bewaesserungsaktor"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index fe709570239..84f224d9ede 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from homewizard_energy.errors import RequestError +from homewizard_energy.models import CombinedModels, Measurement, State, System import pytest from syrupy.assertion import SnapshotAssertion @@ -921,3 +922,148 @@ async def test_entities_not_created_for_device( """Ensures entities for a specific device are not created.""" for entity_id in entity_ids: assert not hass.states.get(entity_id) + + +@pytest.mark.parametrize("device_fixture", ["HWE-BAT"]) +@pytest.mark.freeze_time("2021-01-01 12:00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_uptime_sensor_does_not_update_timestamp_on_data_update( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test that the uptime sensor does not update its timestamp when refreshing data.""" + entity_id = "sensor.device_uptime" + + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, + measurement=Measurement(), + system=System(uptime_s=356), + state=State(), + ) + + # Initial state + assert (state := hass.states.get(entity_id)) + assert state.state == "2021-01-01T11:54:04+00:00" + + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, + measurement=Measurement(), + system=System(uptime_s=356 + UPDATE_INTERVAL.seconds), + state=State(), + ) + + # Uptime should be the same after the initial setup + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + # Check that the uptime sensor has updated + assert (state := hass.states.get(entity_id)) + assert state.state == "2021-01-01T11:54:04+00:00" + + +@pytest.mark.parametrize("device_fixture", ["HWE-BAT"]) +@pytest.mark.freeze_time("2021-01-01 12:00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_uptime_sensor_does_not_update_timestamp_on_minor_change( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test that the uptime sensor does not update its timestamp on minor changes.""" + entity_id = "sensor.device_uptime" + + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, + measurement=Measurement(), + system=System(uptime_s=356), + state=State(), + ) + + # Initial state + assert (state := hass.states.get(entity_id)) + assert state.state == "2021-01-01T11:54:04+00:00" + + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, + measurement=Measurement(), + system=System(uptime_s=400 + UPDATE_INTERVAL.seconds), + state=State(), + ) + + # Uptime should be the same after the initial setup + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + # Check that the uptime sensor has updated + assert (state := hass.states.get(entity_id)) + assert state.state == "2021-01-01T11:54:04+00:00" + + +@pytest.mark.parametrize("device_fixture", ["HWE-BAT"]) +@pytest.mark.freeze_time("2021-01-01 12:00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_uptime_sensor_refreshes_when_detecting_reboot( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test that the uptime sensor updates its timestamp on reboot.""" + entity_id = "sensor.device_uptime" + + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, + measurement=Measurement(), + system=System(uptime_s=356), + state=State(), + ) + + # Initial state + assert (state := hass.states.get(entity_id)) + assert state.state == "2021-01-01T11:54:04+00:00" + + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, measurement=Measurement(), system=System(uptime_s=0), state=State() + ) + + # Simulate a reboot by setting uptime to 0, timestamp should update + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + # Check that the uptime sensor has updated + assert (state := hass.states.get(entity_id)) + assert state.state == "2021-01-01T12:00:00+00:00" + + +@pytest.mark.parametrize("device_fixture", ["HWE-BAT"]) +@pytest.mark.freeze_time("2021-01-01 12:00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_uptime_sensor_unavailable( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test that the uptime sensor reports unavailable when uptime is None.""" + entity_id = "sensor.device_uptime" + + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, + measurement=Measurement(), + system=System(uptime_s=356), + state=State(), + ) + + # Initial state + assert (state := hass.states.get(entity_id)) + assert state.state == "2021-01-01T11:54:04+00:00" + + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, + measurement=Measurement(), + system=System(uptime_s=None), + state=State(), + ) + + # Uptime should be the same after the initial setup + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + # Check that the uptime sensor has updated + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 3d718f24c50..321ffa20508 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2363,5 +2363,199 @@ "sensitivity_max": 4 }, "type": "motion" + }, + { + "id": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "owner": { + "rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "rtype": "motion_area_configuration" + }, + "enabled": true, + "motion": { + "motion": false, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-23T08:13:42.394Z", + "motion": false + } + }, + "sensitivity": { + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "convenience_area_motion" + }, + { + "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "owner": { + "rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "rtype": "motion_area_configuration" + }, + "enabled": true, + "motion": { + "motion": false, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-23T05:54:08.166Z", + "motion": false + } + }, + "sensitivity": { + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "security_area_motion" + }, + { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "name": "Motion Aware Sensor 1", + "group": { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + "participants": [ + { + "resource": { + "rid": "a17253ed-168d-471a-8e59-01a101441511", + "rtype": "motion_area_candidate" + }, + "status": { + "health": "healthy" + } + } + ], + "services": [ + { + "rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "rtype": "convenience_area_motion" + }, + { + "rid": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "rtype": "security_area_motion" + } + ], + "health": "healthy", + "enabled": true, + "type": "motion_area_configuration" + }, + { + "id": "9f8e7d6c-5b4a-3e2d-1c0b-9a8f7e6d5c4b", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "state": "no_update", + "problems": [], + "type": "device_software_update" + }, + { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "owner": { + "rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "rtype": "service_group" + }, + "enabled": true, + "light": { + "light_level_report": { + "changed": "2023-09-23T06:19:38.865Z", + "light_level": 0 + } + }, + "type": "grouped_light_level" + }, + { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "owner": { + "rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "rtype": "service_group" + }, + "enabled": true, + "motion": { + "motion_report": { + "changed": "2023-09-23T08:20:51.384Z", + "motion": false + } + }, + "type": "grouped_motion" + }, + { + "id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f", + "id_v1": "/sensors/75", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "relative_rotary": { + "last_event": { + "action": "start", + "rotation": { + "direction": "clock_wise", + "steps": 30, + "duration": 400 + } + }, + "rotary_report": { + "updated": "2023-09-21T10:00:03.276Z", + "action": "start", + "rotation": { + "direction": "counter_clock_wise", + "steps": 45, + "duration": 400 + } + } + }, + "type": "relative_rotary" + }, + { + "id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "children": [ + { + "rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "rtype": "convenience_area_motion" + }, + { + "rid": "5f317b69-9da0-4b4f-84f2-7ca07b9fe346", + "rtype": "security_area_motion" + } + ], + "services": [ + { + "rid": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "rtype": "grouped_motion" + }, + { + "rid": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "rtype": "grouped_light_level" + } + ], + "metadata": { + "name": "Sensor group" + }, + "type": "service_group" + }, + { + "id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b", + "name": "Test clip resource", + "type": "clip" + }, + { + "id": "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c", + "type": "matter", + "enabled": true, + "max_fabrics": 5, + "has_qr_code": false + }, + { + "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", + "time": { + "time_zone": "UTC", + "time": "2023-09-23T10:30:00Z" + }, + "type": "time" + }, + { + "id": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e", + "status": "ready", + "type": "zigbee_device_discovery" } ] diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index b9c21a5231f..8fc2043d45a 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -19,8 +19,7 @@ async def test_binary_sensors( await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 5 binary_sensors should be created from test data - assert len(hass.states.async_all()) == 5 + # 7 binary_sensors should be created from test data # test motion sensor sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") @@ -81,6 +80,20 @@ async def test_binary_sensors( assert sensor.name == "Test Camera Motion" assert sensor.attributes["device_class"] == "motion" + # test grouped motion sensor + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Sensor group Motion" + assert sensor.attributes["device_class"] == "motion" + + # test motion aware sensor + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Motion Aware Sensor 1" + assert sensor.attributes["device_class"] == "motion" + async def test_binary_sensor_add_update( hass: HomeAssistant, mock_bridge_v2: Mock @@ -110,3 +123,107 @@ async def test_binary_sensor_add_update( test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "on" + # NEW: prefer motion_report.motion when present (should turn on even if plain motion is False) + updated_sensor = { + **FAKE_BINARY_SENSOR, + "motion": { + "motion": False, + "motion_report": {"changed": "2025-01-01T00:00:00Z", "motion": True}, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + assert hass.states.get(test_entity_id).state == "on" + + # NEW: motion_report False should turn it off (even if plain motion is True) + updated_sensor = { + **FAKE_BINARY_SENSOR, + "motion": { + "motion": True, + "motion_report": {"changed": "2025-01-01T00:00:01Z", "motion": False}, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + assert hass.states.get(test_entity_id).state == "off" + + +async def test_grouped_motion_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueGroupedMotionSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + # test grouped motion sensor exists and has correct state + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.attributes["device_class"] == "motion" + + # test update of grouped motion sensor works on incoming event + updated_sensor = { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "type": "grouped_motion", + "motion": { + "motion_report": {"changed": "2023-09-23T08:20:51.384Z", "motion": True} + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor.state == "on" + + # test disabled grouped motion sensor == state unknown + disabled_sensor = { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "type": "grouped_motion", + "enabled": False, + } + mock_bridge_v2.api.emit_event("update", disabled_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor.state == "unknown" + + +async def test_motion_aware_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueMotionAwareSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + # test motion aware sensor exists and has correct state + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.state == "off" + assert sensor.attributes["device_class"] == "motion" + + # test update of motion aware sensor works on incoming event + updated_sensor = { + "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "type": "security_area_motion", + "motion": { + "motion": True, + "motion_valid": True, + "motion_report": {"changed": "2023-09-23T05:54:08.166Z", "motion": True}, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor.state == "on" + + # test name update when motion area configuration name changes + updated_config = { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "type": "motion_area_configuration", + "name": "Updated Motion Area", + } + mock_bridge_v2.api.emit_event("update", updated_config) + await hass.async_block_till_done() + # The entity name is derived from the motion area configuration name + # but the entity ID doesn't change - we just verify the sensor still exists + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.name == "Updated Motion Area" diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index e4bdda422d1..bc63343f9be 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -4,7 +4,7 @@ from ipaddress import ip_address from unittest.mock import Mock, patch from aiohue.discovery import URL_NUPNP -from aiohue.errors import LinkButtonNotPressed +from aiohue.errors import AiohueException, LinkButtonNotPressed import pytest import voluptuous as vol @@ -732,3 +732,216 @@ async def test_bridge_connection_failed( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_bsb003_bridge_discovery( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(const.DOMAIN, "bsb002_00000")}, + connections={(dr.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF")}, + ) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb002_00000"), ("192.168.1.218", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.218", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ) as mock_bridge, + ): + mock_bridge.return_value.fetch_full_state.return_value = {} + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.218"), + ip_addresses=[ip_address("192.168.1.218")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migrated_bridge" + + migrated_device = device_registry.async_get(device.id) + + assert migrated_device is not None + assert len(migrated_device.identifiers) == 1 + assert list(migrated_device.identifiers)[0] == (const.DOMAIN, "bsb003_00000") + # The tests don't add new connection, but that will happen + # outside of the config flow + assert len(migrated_device.connections) == 0 + assert entry.data["host"] == "192.168.1.218" + + +async def test_bsb003_bridge_discovery_old_version( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 1, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.218", supports_v2=True + ) + + with patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.218"), + ip_addresses=[ip_address("192.168.1.218")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" + + +async def test_bsb003_bridge_discovery_same_host( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.217", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" + + +@pytest.mark.parametrize("exception", [AiohueException, ClientError]) +async def test_bsb003_bridge_discovery_cannot_connect( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + exception: Exception, +) -> None: + """Test a bridge being discovered.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + data={"host": "192.168.1.217", "api_version": 2, "api_key": "abc"}, + unique_id="bsb002_00000", + ) + entry.add_to_hass(hass) + create_mock_api_discovery( + aioclient_mock, + [("192.168.1.217", "bsb003_00000")], + ) + disc_bridge = get_discovered_bridge( + bridge_id="bsb003_00000", host="192.168.1.217", supports_v2=True + ) + + with ( + patch( + "homeassistant.components.hue.config_flow.discover_bridge", + return_value=disc_bridge, + ), + patch( + "homeassistant.components.hue.config_flow.HueBridgeV2", + autospec=True, + ) as mock_bridge, + ): + mock_bridge.return_value.fetch_full_state.side_effect = exception + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "bridgeid": "bsb003_00000", + "modelid": "BSB003", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py index 88b44165687..73ae1e5d1d5 100644 --- a/tests/components/hue/test_event.py +++ b/tests/components/hue/test_event.py @@ -17,8 +17,8 @@ async def test_event( """Test event entity for Hue integration.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, Platform.EVENT) - # 7 entities should be created from test data - assert len(hass.states.async_all()) == 7 + # 8 entities should be created from test data + assert len(hass.states.async_all()) == 8 # pick one of the remote buttons state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 83b2bd48b3c..a5e7d24c86e 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -178,7 +178,7 @@ async def test_light_turn_on_service( blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 6 - assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500 + assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 454 # test enable an effect await hass.services.async_call( @@ -518,9 +518,8 @@ async def test_grouped_lights( } mock_bridge_v2.api.emit_event("update", event) await hass.async_block_till_done() - await hass.async_block_till_done() - # the light should now be on and have the properties we've set + # The light should now be on and have the properties we've set test_light = hass.states.get(test_light_id) assert test_light is not None assert test_light.state == "on" @@ -528,6 +527,364 @@ async def test_grouped_lights( assert test_light.attributes["brightness"] == 255 assert test_light.attributes["xy_color"] == (0.123, 0.123) + # While we have a group on, test the color aggregation logic, XY first + + # Turn off one of the bulbs in the group + # "hue_light_with_color_and_color_temperature_1" corresponds to "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1" + mock_bridge_v2.mock_requests.clear() + single_light_id = "light.hue_light_with_color_and_color_temperature_1" + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": single_light_id}, + blocking=True, + ) + event = { + "id": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # The group should still show the same XY color since other lights maintain their color + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["xy_color"] == (0.123, 0.123) + + # Turn the light back on with a white XY color (different from the rest of the group) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": single_light_id, "xy_color": [0.3127, 0.3290]}, + blocking=True, + ) + event = { + "id": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "type": "light", + "on": {"on": True}, + "color": {"xy": {"x": 0.3127, "y": 0.3290}}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # Now the group XY color should be the average of all three lights: + # Light 1: (0.3127, 0.3290) - white + # Light 2: (0.123, 0.123) + # Light 3: (0.123, 0.123) + # Average: ((0.3127 + 0.123 + 0.123) / 3, (0.3290 + 0.123 + 0.123) / 3) + # Average: (0.1862, 0.1917) rounded to 4 decimal places + expected_x = round((0.3127 + 0.123 + 0.123) / 3, 4) + expected_y = round((0.3290 + 0.123 + 0.123) / 3, 4) + + # Check that the group XY color is now the average of all lights + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + group_x, group_y = test_light.attributes["xy_color"] + assert abs(group_x - expected_x) < 0.001 # Allow small floating point differences + assert abs(group_y - expected_y) < 0.001 + + # Test turning off another light in the group, leaving only two lights on - one white and one original color + # "hue_light_with_color_and_color_temperature_2" corresponds to "b3fe71ef-d0ef-48de-9355-d9e604377df0" + second_light_id = "light.hue_light_with_color_and_color_temperature_2" + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": second_light_id}, + blocking=True, + ) + + # Simulate the second light turning off + event = { + "id": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # Now only two lights are on: + # Light 1: (0.3127, 0.3290) - white + # Light 3: (0.123, 0.123) - original color + # Average of remaining lights: ((0.3127 + 0.123) / 2, (0.3290 + 0.123) / 2) + expected_x_two_lights = round((0.3127 + 0.123) / 2, 4) + expected_y_two_lights = round((0.3290 + 0.123) / 2, 4) + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + # Check that the group color is now the average of only the two remaining lights + group_x, group_y = test_light.attributes["xy_color"] + assert abs(group_x - expected_x_two_lights) < 0.001 + assert abs(group_y - expected_y_two_lights) < 0.001 + + # Test colour temperature aggregation + # Set all three lights to colour temperature mode with different mirek values + for mirek, light_name, light_id in zip( + [300, 250, 200], + [ + "light.hue_light_with_color_and_color_temperature_1", + "light.hue_light_with_color_and_color_temperature_2", + "light.hue_light_with_color_and_color_temperature_gradient", + ], + [ + "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "8015b17f-8336-415b-966a-b364bd082397", + ], + strict=True, + ): + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": light_name, + "color_temp": mirek, + }, + blocking=True, + ) + # Emit update event with matching mirek value + mock_bridge_v2.api.emit_event( + "update", + { + "id": light_id, + "type": "light", + "on": {"on": True}, + "color_temperature": {"mirek": mirek, "mirek_valid": True}, + }, + ) + await hass.async_block_till_done() + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + # Expected average kelvin calculation: + # 300 mirek ≈ 3333K, 250 mirek ≈ 4000K, 200 mirek ≈ 5000K + expected_avg_kelvin = round((3333 + 4000 + 5000) / 3) + assert abs(test_light.attributes["color_temp_kelvin"] - expected_avg_kelvin) <= 5 + + # Switch light 3 off and check average kelvin temperature of remaining two lights + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.hue_light_with_color_and_color_temperature_gradient"}, + blocking=True, + ) + event = { + "id": "8015b17f-8336-415b-966a-b364bd082397", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + # Expected average kelvin calculation: + # 300 mirek ≈ 3333K, 250 mirek ≈ 4000K + expected_avg_kelvin = round((3333 + 4000) / 2) + assert abs(test_light.attributes["color_temp_kelvin"] - expected_avg_kelvin) <= 5 + + # Turn light 3 back on in XY mode and verify majority still favours COLOR_TEMP + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.hue_light_with_color_and_color_temperature_gradient", + "xy_color": [0.123, 0.123], + }, + blocking=True, + ) + mock_bridge_v2.api.emit_event( + "update", + { + "id": "8015b17f-8336-415b-966a-b364bd082397", + "type": "light", + "on": {"on": True}, + "color": {"xy": {"x": 0.123, "y": 0.123}}, + "color_temperature": { + "mirek": None, + "mirek_valid": False, + }, + }, + ) + await hass.async_block_till_done() + + test_light = hass.states.get(test_light_id) + assert test_light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + # Switch light 2 to XY mode to flip the majority + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.hue_light_with_color_and_color_temperature_2", + "xy_color": [0.321, 0.321], + }, + blocking=True, + ) + mock_bridge_v2.api.emit_event( + "update", + { + "id": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "type": "light", + "on": {"on": True}, + "color": {"xy": {"x": 0.321, "y": 0.321}}, + "color_temperature": { + "mirek": None, + "mirek_valid": False, + }, + }, + ) + await hass.async_block_till_done() + + test_light = hass.states.get(test_light_id) + assert test_light.attributes["color_mode"] == ColorMode.XY + + # Test brightness aggregation with different brightness levels + mock_bridge_v2.mock_requests.clear() + + # Set all three lights to different brightness levels + for brightness, light_name, light_id in zip( + [90.0, 60.0, 30.0], + [ + "light.hue_light_with_color_and_color_temperature_1", + "light.hue_light_with_color_and_color_temperature_2", + "light.hue_light_with_color_and_color_temperature_gradient", + ], + [ + "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "8015b17f-8336-415b-966a-b364bd082397", + ], + strict=True, + ): + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": light_name, + "brightness": brightness, + }, + blocking=True, + ) + # Emit update event with matching brightness value + mock_bridge_v2.api.emit_event( + "update", + { + "id": light_id, + "type": "light", + "on": {"on": True}, + "dimming": {"brightness": brightness}, + }, + ) + await hass.async_block_till_done() + + # Check that the group brightness is the average of all three lights + # Expected average: (90 + 60 + 30) / 3 = 60% -> 153 (60% of 255) + expected_brightness = round(((90 + 60 + 30) / 3 / 100) * 255) + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["brightness"] == expected_brightness + + # Turn off the dimmest light 3 (30% brightness) while keeping the other two on + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.hue_light_with_color_and_color_temperature_gradient"}, + blocking=True, + ) + event = { + "id": "8015b17f-8336-415b-966a-b364bd082397", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # Check that the group brightness is now the average of the two remaining lights + # Expected average: (90 + 60) / 2 = 75% -> 191 (75% of 255) + expected_brightness_two_lights = round(((90 + 60) / 2 / 100) * 255) + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["brightness"] == expected_brightness_two_lights + + # Turn off light 2 (60% brightness), leaving only the brightest one + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.hue_light_with_color_and_color_temperature_2"}, + blocking=True, + ) + event = { + "id": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # Check that the group brightness is now just the remaining light's brightness + # Expected brightness: 90% -> 230 (round(90 / 100 * 255)) + expected_brightness_one_light = round((90 / 100) * 255) + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["brightness"] == expected_brightness_one_light + + # Set all three lights back to 100% brightness for consistency with later tests + for light_name, light_id in zip( + [ + "light.hue_light_with_color_and_color_temperature_1", + "light.hue_light_with_color_and_color_temperature_2", + "light.hue_light_with_color_and_color_temperature_gradient", + ], + [ + "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "8015b17f-8336-415b-966a-b364bd082397", + ], + strict=True, + ): + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": light_name, + "brightness": 100.0, + }, + blocking=True, + ) + # Emit update event with matching brightness value + mock_bridge_v2.api.emit_event( + "update", + { + "id": light_id, + "type": "light", + "on": {"on": True}, + "dimming": {"brightness": 100.0}, + }, + ) + await hass.async_block_till_done() + + # Verify group is back to 100% brightness + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["brightness"] == 255 + # Test calling the turn off service on a grouped light. mock_bridge_v2.mock_requests.clear() await hass.services.async_call( diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 7c5afae3371..e7b90c2015d 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -27,8 +27,8 @@ async def test_sensors( await setup_platform(hass, mock_bridge_v2, Platform.SENSOR) # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 6 entities should be created from test data - assert len(hass.states.async_all()) == 6 + # 7 entities should be created from test data + assert len(hass.states.async_all()) == 7 # test temperature sensor sensor = hass.states.get("sensor.hue_motion_sensor_temperature") @@ -59,6 +59,16 @@ async def test_sensors( assert sensor.attributes["unit_of_measurement"] == "%" assert sensor.attributes["battery_state"] == "normal" + # test grouped light level sensor + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert sensor is not None + assert sensor.state == "0" + assert sensor.attributes["friendly_name"] == "Sensor group Illuminance" + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "lx" + assert sensor.attributes["light_level"] == 0 + # test disabled zigbee_connectivity sensor entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" entity_entry = entity_registry.async_get(entity_id) @@ -139,3 +149,39 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2: Mock) -> N test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "22.5" + + +async def test_grouped_light_level_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueGroupedLightLevelSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.SENSOR) + + # test grouped light level sensor exists and has correct state + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert sensor is not None + assert ( + sensor.state == "0" + ) # Light level 0 translates to 10^((0-1)/10000) ≈ 0 lux (rounded) + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["light_level"] == 0 + + # test update of grouped light level sensor works on incoming event + updated_sensor = { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "type": "grouped_light_level", + "light": { + "light_level": 30000, + "light_level_report": { + "changed": "2023-09-23T08:20:51.384Z", + "light_level": 30000, + }, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert ( + sensor.state == "999" + ) # Light level 30000 translates to 10^((30000-1)/10000) ≈ 999 lux diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 1cd6f9b393e..02b9b2715a1 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -1,7 +1,7 @@ """Test helpers for Husqvarna Automower.""" import asyncio -from collections.abc import Generator +from collections.abc import Callable, Generator import time from unittest.mock import AsyncMock, create_autospec, patch @@ -16,7 +16,7 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.husqvarna_automower.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -137,3 +137,21 @@ def mock_automower_client( spec_set=True, ) yield mock_instance + + +@pytest.fixture +def automower_ws_ready(mock_automower_client: AsyncMock) -> list[Callable[[], None]]: + """Fixture to capture ws_ready_callbacks.""" + + ws_ready_callbacks: list[Callable[[], None]] = [] + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + ws_ready_callbacks.append(cb) + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + mock_automower_client.send_empty_message.return_value = True + + return ws_ready_callbacks diff --git a/tests/components/husqvarna_automower/test_event.py b/tests/components/husqvarna_automower/test_event.py index 6cbfa102976..c4121c1cfb8 100644 --- a/tests/components/husqvarna_automower/test_event.py +++ b/tests/components/husqvarna_automower/test_event.py @@ -33,6 +33,7 @@ async def test_event( mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, values: dict[str, MowerAttributes], + automower_ws_ready: list[Callable[[], None]], ) -> None: """Test that a new message arriving over the websocket creates and updates the sensor.""" callbacks: list[Callable[[SingleMessageData], None]] = [] @@ -46,11 +47,17 @@ async def test_event( mock_automower_client.register_single_message_callback.side_effect = ( fake_register_websocket_response ) + mock_automower_client.send_empty_message.return_value = True # Set up integration await setup_integration(hass, mock_config_entry) await hass.async_block_till_done() + # Start the watchdog and let it run once to set websocket_alive=True + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + # Ensure callback was registered for the test mower assert mock_automower_client.register_single_message_callback.called @@ -76,6 +83,7 @@ async def test_event( for cb in callbacks: cb(message) await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") assert state is not None assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" @@ -84,6 +92,12 @@ async def test_event( await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED + + # Start the new watchdog and let it run + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") assert state is not None assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" @@ -129,6 +143,7 @@ async def test_event( for cb in callbacks: cb(message) await hass.async_block_till_done() + entry = entity_registry.async_get("event.test_mower_1_message") assert entry is not None assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" @@ -154,9 +169,9 @@ async def test_event_snapshot( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + automower_ws_ready: list[Callable[[], None]], ) -> None: """Test that a new message arriving over the websocket updates the sensor.""" with patch( @@ -179,6 +194,11 @@ async def test_event_snapshot( await setup_integration(hass, mock_config_entry) await hass.async_block_till_done() + # Start the watchdog and let it run once + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + # Ensure callback was registered for the test mower assert mock_automower_client.register_single_message_callback.called diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index a157380ab3c..271b381d32f 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -525,10 +525,11 @@ async def test_dynamic_polling( mock_automower_client.register_data_callback.side_effect = ( fake_register_websocket_response ) + ws_ready_callbacks: list[Callable[[], None]] = [] @callback def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: - callback_holder["ws_ready_cb"] = cb + ws_ready_callbacks.append(cb) mock_automower_client.register_ws_ready_callback.side_effect = ( fake_register_ws_ready_callback @@ -536,8 +537,8 @@ async def test_dynamic_polling( await setup_integration(hass, mock_config_entry) - assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" - callback_holder["ws_ready_cb"]() + for cb in ws_ready_callbacks: + cb() await hass.async_block_till_done() assert mock_automower_client.get_status.call_count == 1 @@ -631,10 +632,11 @@ async def test_websocket_watchdog( mock_automower_client.register_data_callback.side_effect = ( fake_register_websocket_response ) + ws_ready_callbacks: list[Callable[[], None]] = [] @callback def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: - callback_holder["ws_ready_cb"] = cb + ws_ready_callbacks.append(cb) mock_automower_client.register_ws_ready_callback.side_effect = ( fake_register_ws_ready_callback @@ -642,8 +644,8 @@ async def test_websocket_watchdog( await setup_integration(hass, mock_config_entry) - assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" - callback_holder["ws_ready_cb"]() + for cb in ws_ready_callbacks: + cb() await hass.async_block_till_done() assert mock_automower_client.get_status.call_count == 1 diff --git a/tests/components/husqvarna_automower_ble/__init__.py b/tests/components/husqvarna_automower_ble/__init__.py index 7ca5aea121d..fbb2a67ab9a 100644 --- a/tests/components/husqvarna_automower_ble/__init__.py +++ b/tests/components/husqvarna_automower_ble/__init__.py @@ -9,16 +9,23 @@ from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -AUTOMOWER_SERVICE_INFO = BluetoothServiceInfo( +AUTOMOWER_SERVICE_INFO_SERIAL = BluetoothServiceInfo( name="305", address="00000000-0000-0000-0000-000000000003", rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - "00001800-0000-1000-8000-00805f9b34fb", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +AUTOMOWER_SERVICE_INFO_MOWER = BluetoothServiceInfo( + name="305", + address="00000000-0000-0000-0000-000000000003", + rssi=-63, + service_data={}, + manufacturer_data={1062: bytes.fromhex("02050104060a2301")}, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -28,10 +35,7 @@ AUTOMOWER_UNNAMED_SERVICE_INFO = BluetoothServiceInfo( rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - "00001800-0000-1000-8000-00805f9b34fb", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -41,10 +45,7 @@ AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO = BluetoothServiceInfo( rssi=-63, service_data={}, manufacturer_data={}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - "00001800-0000-1000-8000-00805f9b34fb", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -54,9 +55,30 @@ AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO = BluetoothServiceInfo( rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +MISSING_SERVICE_SERVICE_INFO = BluetoothServiceInfo( + name="Blah", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", +) + + +WATER_TIMER_SERVICE_INFO = BluetoothServiceInfo( + name="Timer", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={ + 1062: b"\x02\x07d\x02\x05\x01\x02\x08\x00\x02\t\x01\x04\x06\x12\x00\x01" + }, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -66,7 +88,7 @@ async def setup_entry( ) -> None: """Make sure the device is available.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) + inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO_SERIAL) with patch("homeassistant.components.husqvarna_automower_ble.PLATFORMS", platforms): mock_entry.add_to_hass(hass) diff --git a/tests/components/husqvarna_automower_ble/conftest.py b/tests/components/husqvarna_automower_ble/conftest.py index 1081db014e3..f5aebf54b7a 100644 --- a/tests/components/husqvarna_automower_ble/conftest.py +++ b/tests/components/husqvarna_automower_ble/conftest.py @@ -7,9 +7,9 @@ from automower_ble.protocol import ResponseResult import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN -from . import AUTOMOWER_SERVICE_INFO +from . import AUTOMOWER_SERVICE_INFO_SERIAL from tests.common import MockConfigEntry @@ -56,8 +56,9 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, title="Husqvarna AutoMower", data={ - CONF_ADDRESS: AUTOMOWER_SERVICE_INFO.address, + CONF_ADDRESS: AUTOMOWER_SERVICE_INFO_SERIAL.address, CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", }, - unique_id=AUTOMOWER_SERVICE_INFO.address, + unique_id=AUTOMOWER_SERVICE_INFO_SERIAL.address, ) diff --git a/tests/components/husqvarna_automower_ble/test_config_flow.py b/tests/components/husqvarna_automower_ble/test_config_flow.py index e053a28b7dd..967502f284d 100644 --- a/tests/components/husqvarna_automower_ble/test_config_flow.py +++ b/tests/components/husqvarna_automower_ble/test_config_flow.py @@ -2,19 +2,24 @@ from unittest.mock import Mock, patch +from automower_ble.protocol import ResponseResult from bleak import BleakError import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from . import ( - AUTOMOWER_SERVICE_INFO, + AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, + AUTOMOWER_SERVICE_INFO_MOWER, + AUTOMOWER_SERVICE_INFO_SERIAL, AUTOMOWER_UNNAMED_SERVICE_INFO, - AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO, + MISSING_SERVICE_SERVICE_INFO, + WATER_TIMER_SERVICE_INFO, ) from tests.common import MockConfigEntry @@ -36,8 +41,6 @@ def mock_random() -> Mock: async def test_user_selection(hass: HomeAssistant) -> None: """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_init( @@ -46,16 +49,28 @@ async def test_user_selection(hass: HomeAssistant) -> None: 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_ADDRESS: "00000000-0000-0000-0000-000000000001"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" + # mock connection error + with patch( + "homeassistant.components.husqvarna_automower_ble.config_flow.HusqvarnaAutomowerBleConfigFlow.probe_mower", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Husqvarna Automower" @@ -64,23 +79,88 @@ async def test_user_selection(hass: HomeAssistant) -> None: assert result["data"] == { CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", } -async def test_bluetooth(hass: HomeAssistant) -> None: - """Test bluetooth device discovery.""" +async def test_user_selection_incorrect_pin( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - await hass.async_block_till_done(wait_background_tasks=True) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] - assert result["step_id"] == "confirm" - assert result["context"]["unique_id"] == "00000000-0000-0000-0000-000000000003" + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "ABCD", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_pin"} + + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", + } + + +@pytest.mark.parametrize( + "service_info", + [AUTOMOWER_SERVICE_INFO_MOWER, AUTOMOWER_SERVICE_INFO_SERIAL], +) +async def test_bluetooth( + hass: HomeAssistant, service_info: BluetoothServiceInfo +) -> None: + """Test bluetooth device discovery.""" + + inject_bluetooth_service_info(hass, service_info) + await hass.async_block_till_done(wait_background_tasks=True) + + result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "1234"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Husqvarna Automower" assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" @@ -88,35 +168,247 @@ async def test_bluetooth(hass: HomeAssistant) -> None: assert result["data"] == { CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", } -async def test_bluetooth_invalid(hass: HomeAssistant) -> None: - """Test bluetooth device discovery with invalid data.""" - - inject_bluetooth_service_info(hass, AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - -async def test_failed_connect( +async def test_bluetooth_incorrect_pin( hass: HomeAssistant, mock_automower_client: Mock, ) -> None: """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) - mock_automower_client.connect.side_effect = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_SERVICE_INFO_SERIAL, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "ABCD", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] == {"base": "invalid_pin"} + + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "1234"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Husqvarna Automower" + assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" + + assert result["data"] == { + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", + } + + +async def test_bluetooth_unknown_error( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_SERVICE_INFO_SERIAL, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.UNKNOWN_ERROR + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + +async def test_bluetooth_not_paired( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_SERVICE_INFO_SERIAL, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.NOT_ALLOWED + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.OK + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "1234"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Husqvarna Automower" + assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" + + assert result["data"] == { + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", + } + + +@pytest.mark.parametrize( + "service_info", + [ + AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, + MISSING_SERVICE_SERVICE_INFO, + WATER_TIMER_SERVICE_INFO, + ], +) +async def test_bluetooth_invalid( + hass: HomeAssistant, service_info: BluetoothServiceInfo +) -> None: + """Test bluetooth device discovery with invalid data.""" + + inject_bluetooth_service_info(hass, service_info) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=service_info, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_successful_reauth( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) + + await hass.async_block_till_done(wait_background_tasks=True) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "ABCD", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_pin"} + + # Try connection error + mock_automower_client.connect.return_value = ResponseResult.UNKNOWN_ERROR + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "5678", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "5678", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "1234", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries("husqvarna_automower_ble")) == 1 + + assert ( + mock_config_entry.data[CONF_ADDRESS] == "00000000-0000-0000-0000-000000000003" + ) + assert mock_config_entry.data[CONF_CLIENT_ID] == 1197489078 + assert mock_config_entry.data[CONF_PIN] == "1234" + + +async def test_user_unable_to_connect( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + await hass.async_block_till_done(wait_background_tasks=True) + + mock_automower_client.connect.side_effect = BleakError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -126,23 +418,41 @@ async def test_failed_connect( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000001"}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_failed_reauth( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) + + await hass.async_block_till_done(wait_background_tasks=True) + + mock_automower_client.connect.side_effect = BleakError + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" + assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={ + CONF_PIN: "5678", + }, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Husqvarna Automower" - assert result["result"].unique_id == "00000000-0000-0000-0000-000000000001" - - assert result["data"] == { - CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", - CONF_CLIENT_ID: 1197489078, - } + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} async def test_duplicate_entry( @@ -154,8 +464,6 @@ async def test_duplicate_entry( mock_config_entry.add_to_hass(hass) - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - await hass.async_block_till_done(wait_background_tasks=True) # Test we should not discover the already configured device @@ -169,30 +477,62 @@ async def test_duplicate_entry( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000003"}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_PIN: "1234", + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_exception_connect( +async def test_exception_probe( hass: HomeAssistant, mock_automower_client: Mock, ) -> None: """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) mock_automower_client.probe_gatts.side_effect = BleakError result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] - assert result["step_id"] == "confirm" + assert result["step_id"] == "bluetooth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={CONF_PIN: "1234"}, ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_exception_connect( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) + + await hass.async_block_till_done(wait_background_tasks=True) + + mock_automower_client.connect.side_effect = BleakError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" diff --git a/tests/components/husqvarna_automower_ble/test_init.py b/tests/components/husqvarna_automower_ble/test_init.py index 95a0a1f2037..f10ae1fa743 100644 --- a/tests/components/husqvarna_automower_ble/test_init.py +++ b/tests/components/husqvarna_automower_ble/test_init.py @@ -8,10 +8,11 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import AUTOMOWER_SERVICE_INFO +from . import AUTOMOWER_SERVICE_INFO_SERIAL from tests.common import MockConfigEntry @@ -33,13 +34,44 @@ async def test_setup( assert mock_config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, f"{AUTOMOWER_SERVICE_INFO.address}_1197489078")} + identifiers={(DOMAIN, f"{AUTOMOWER_SERVICE_INFO_SERIAL.address}_1197489078")} ) assert device_entry == snapshot -async def test_setup_retry_connect( +async def test_setup_missing_pin( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test a setup that was created before PIN support.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_CLIENT_ID: "1197489078", + }, + ) + + 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.SETUP_ERROR + + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_PIN: 1234}, + ) + + assert len(hass.config_entries.flow.async_progress()) == 1 + await hass.async_block_till_done() + + +async def test_setup_failed_connect( hass: HomeAssistant, mock_automower_client: Mock, mock_config_entry: MockConfigEntry, @@ -68,3 +100,18 @@ async def test_setup_unknown_error( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_invalid_pin( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unable to connect due to incorrect PIN.""" + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + + 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.SETUP_ERROR diff --git a/tests/components/husqvarna_automower_ble/test_lawn_mower.py b/tests/components/husqvarna_automower_ble/test_lawn_mower.py index 2a127c785d9..25e02a43acc 100644 --- a/tests/components/husqvarna_automower_ble/test_lawn_mower.py +++ b/tests/components/husqvarna_automower_ble/test_lawn_mower.py @@ -156,7 +156,7 @@ OPERATIONAL_STATES = [ # Operational states are mapped according to the activity ( OPERATIONAL_STATES, - [MowerActivity.CHARGING, MowerActivity.NONE, MowerActivity.PARKED], + [MowerActivity.CHARGING, MowerActivity.PARKED], LawnMowerActivity.DOCKED, ), ( @@ -174,6 +174,17 @@ OPERATIONAL_STATES = [ [MowerActivity.STOPPED_IN_GARDEN], LawnMowerActivity.ERROR, ), + # Special case for MowerActivity.NONE + ( + [MowerState.IN_OPERATION, MowerState.RESTRICTED], + [MowerActivity.NONE], + LawnMowerActivity.DOCKED, + ), + ( + [MowerState.PENDING_START], + [MowerActivity.NONE], + LawnMowerActivity.ERROR, + ), ], ) async def test_mower_activity_mapping( diff --git a/tests/components/iaqualink/conftest.py b/tests/components/iaqualink/conftest.py index c7e7373f4c2..37e89e4fe52 100644 --- a/tests/components/iaqualink/conftest.py +++ b/tests/components/iaqualink/conftest.py @@ -43,6 +43,7 @@ def get_aqualink_system(aqualink, cls=None, data=None): data = {} num = random.randint(0, 99999) + data["name"] = "Pool" data["serial_number"] = f"SN{num:05}" return cls(aqualink=aqualink, data=data) diff --git a/tests/components/icloud/test_config_flow.py b/tests/components/icloud/test_config_flow.py index c0bc5d7ed2e..427fad63806 100644 --- a/tests/components/icloud/test_config_flow.py +++ b/tests/components/icloud/test_config_flow.py @@ -199,7 +199,7 @@ async def test_user_with_cookie( async def test_login_failed(hass: HomeAssistant) -> None: """Test when we have errors during login.""" with patch( - "homeassistant.components.icloud.config_flow.PyiCloudService.authenticate", + "homeassistant.components.icloud.config_flow.PyiCloudService", side_effect=PyiCloudFailedLoginException(), ): result = await hass.config_entries.flow.async_init( @@ -409,7 +409,7 @@ async def test_password_update_wrong_password(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM with patch( - "homeassistant.components.icloud.config_flow.PyiCloudService.authenticate", + "homeassistant.components.icloud.config_flow.PyiCloudService", side_effect=PyiCloudFailedLoginException(), ): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/idasen_desk/__init__.py b/tests/components/idasen_desk/__init__.py index b0d7cc5ac05..42e00157b8f 100644 --- a/tests/components/idasen_desk/__init__.py +++ b/tests/components/idasen_desk/__init__.py @@ -38,6 +38,8 @@ NOT_IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( tx_power=-127, ) +UPDATE_DEBOUNCE_TIME = 0.2 + async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the IKEA Idasen Desk integration in Home Assistant.""" diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py index 83312c04e72..84861ab6873 100644 --- a/tests/components/idasen_desk/test_cover.py +++ b/tests/components/idasen_desk/test_cover.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from bleak.exc import BleakError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.cover import ( @@ -22,12 +23,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import init_integration +from . import UPDATE_DEBOUNCE_TIME, init_integration + +from tests.common import async_fire_time_changed async def test_cover_available( - hass: HomeAssistant, - mock_desk_api: MagicMock, + hass: HomeAssistant, mock_desk_api: MagicMock, freezer: FrozenDateTimeFactory ) -> None: """Test cover available property.""" entity_id = "cover.test" @@ -42,6 +44,9 @@ async def test_cover_available( mock_desk_api.is_connected = False mock_desk_api.trigger_update_callback(None) + freezer.tick(UPDATE_DEBOUNCE_TIME) + async_fire_time_changed(hass) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNAVAILABLE @@ -64,6 +69,7 @@ async def test_cover_services( service_data: dict[str, Any], expected_state: str, expected_position: int, + freezer: FrozenDateTimeFactory, ) -> None: """Test cover services.""" entity_id = "cover.test" @@ -78,7 +84,9 @@ async def test_cover_services( {"entity_id": entity_id, **service_data}, blocking=True, ) - await hass.async_block_till_done() + freezer.tick(UPDATE_DEBOUNCE_TIME) + async_fire_time_changed(hass) + state = hass.states.get(entity_id) assert state assert state.state == expected_state @@ -113,4 +121,3 @@ async def test_cover_services_exception( {"entity_id": entity_id, **service_data}, blocking=True, ) - await hass.async_block_till_done() diff --git a/tests/components/idasen_desk/test_sensor.py b/tests/components/idasen_desk/test_sensor.py index 614bce523e6..dc8d6f4adf8 100644 --- a/tests/components/idasen_desk/test_sensor.py +++ b/tests/components/idasen_desk/test_sensor.py @@ -2,18 +2,23 @@ from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import init_integration +from . import UPDATE_DEBOUNCE_TIME, init_integration + +from tests.common import async_fire_time_changed EXPECTED_INITIAL_HEIGHT = "1" @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_height_sensor(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: +async def test_height_sensor( + hass: HomeAssistant, mock_desk_api: MagicMock, freezer: FrozenDateTimeFactory +) -> None: """Test height sensor.""" await init_integration(hass) @@ -24,6 +29,15 @@ async def test_height_sensor(hass: HomeAssistant, mock_desk_api: MagicMock) -> N mock_desk_api.height = 1.2 mock_desk_api.trigger_update_callback(None) + await hass.async_block_till_done() + + # State should still be the same due to the debouncer + state = hass.states.get(entity_id) + assert state + assert state.state == EXPECTED_INITIAL_HEIGHT + + freezer.tick(UPDATE_DEBOUNCE_TIME) + async_fire_time_changed(hass) state = hass.states.get(entity_id) assert state @@ -34,6 +48,7 @@ async def test_height_sensor(hass: HomeAssistant, mock_desk_api: MagicMock) -> N async def test_sensor_available( hass: HomeAssistant, mock_desk_api: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test sensor available property.""" await init_integration(hass) @@ -46,6 +61,9 @@ async def test_sensor_available( mock_desk_api.is_connected = False mock_desk_api.trigger_update_callback(None) + freezer.tick(UPDATE_DEBOUNCE_TIME) + async_fire_time_changed(hass) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index bb8762f17e2..0a1c939c474 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -407,6 +407,15 @@ async def test_image_stream( await close_future +async def test_get_image_action(hass: HomeAssistant, mock_image_platform: None) -> None: + """Test get_image action.""" + image_data = await image.async_get_image(hass, "image.test") + assert image_data == image.Image(content_type="image/jpeg", content=b"Test") + + with pytest.raises(HomeAssistantError, match="not found"): + await image.async_get_image(hass, "image.unknown") + + async def test_snapshot_service(hass: HomeAssistant) -> None: """Test snapshot service.""" mopen = mock_open() diff --git a/tests/components/image_upload/test_media_source.py b/tests/components/image_upload/test_media_source.py index d66e099bdc9..9e76a67da8a 100644 --- a/tests/components/image_upload/test_media_source.py +++ b/tests/components/image_upload/test_media_source.py @@ -1,5 +1,6 @@ """Test image_upload media source.""" +from pathlib import Path import tempfile from unittest.mock import patch @@ -7,6 +8,8 @@ from aiohttp import ClientSession import pytest from homeassistant.components import media_source +from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -60,7 +63,7 @@ async def test_browsing( assert item.children[0].thumbnail == f"/api/image/serve/{image_id}/256x256" with pytest.raises( - media_source.BrowseError, + BrowseError, match="Unknown item", ): await media_source.async_browse_media( @@ -79,10 +82,11 @@ async def test_resolving( assert item is not None assert item.url == f"/api/image/serve/{image_id}/original" assert item.mime_type == "image/png" + assert item.path == Path(hass.config.path("image")) / image_id / "original" invalid_id = "aabbccddeeff" with pytest.raises( - media_source.Unresolvable, + Unresolvable, match=f"Could not resolve media item: {invalid_id}", ): await media_source.async_resolve_media( diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 8f6761bd795..5ddf86153cb 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -27,6 +27,9 @@ TEST_MESSAGE_HEADERS2 = ( TEST_MULTIPART_HEADER = ( b'Content-Type: multipart/related;\r\n\tboundary="Mark=_100584970350292485166"' ) +TEST_MULTIPART_ATTACHMENT_HEADER = ( + b'Content-Type: multipart/mixed; boundary="------------qIuh0xG6dsImymfJo6f2M4Zv"' +) TEST_MESSAGE_HEADERS3 = b"" @@ -36,6 +39,13 @@ TEST_MESSAGE_MULTIPART = ( TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 + TEST_MULTIPART_HEADER ) +TEST_MESSAGE_MULTIPART_ATTACHMENT = ( + TEST_MESSAGE_HEADERS1 + + DATE_HEADER1 + + TEST_MESSAGE_HEADERS2 + + TEST_MULTIPART_ATTACHMENT_HEADER +) + TEST_MESSAGE_NO_SUBJECT_TO_FROM = ( TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS3 ) @@ -140,6 +150,45 @@ TEST_CONTENT_MULTIPART_BASE64_INVALID = ( + b"\r\n--Mark=_100584970350292485166--\r\n" ) +TEST_CONTENT_MULTIPART_WITH_ATTACHMENT = b""" +\nThis is a multi-part message in MIME format. +--------------qIuh0xG6dsImymfJo6f2M4Zv +Content-Type: multipart/alternative; + boundary="------------N4zNjp2QWnOfrYQhtLL02Bk1" + +--------------N4zNjp2QWnOfrYQhtLL02Bk1 +Content-Type: text/plain; charset=UTF-8; format=flowed +Content-Transfer-Encoding: 7bit + +*Multi* part Test body + +--------------N4zNjp2QWnOfrYQhtLL02Bk1 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + + + + + + + + +

Multi part Test body

+ + + +--------------N4zNjp2QWnOfrYQhtLL02Bk1-- +--------------qIuh0xG6dsImymfJo6f2M4Zv +Content-Type: text/plain; charset=UTF-8; name="Text attachment content.txt" +Content-Disposition: attachment; filename="Text attachment content.txt" +Content-Transfer-Encoding: base64 + +VGV4dCBhdHRhY2htZW50IGNvbnRlbnQ= + +--------------qIuh0xG6dsImymfJo6f2M4Zv-- +""" + + EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) EMPTY_SEARCH_RESPONSE_ALT = ("OK", [b"Search completed (0.0001 + 0.000 secs)."]) @@ -303,6 +352,24 @@ TEST_FETCH_RESPONSE_MULTIPART_BASE64 = ( b"Fetch completed (0.0001 + 0.000 secs).", ], ) +TEST_FETCH_RESPONSE_MULTIPART_WITH_ATTACHMENT = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str( + len( + TEST_MESSAGE_MULTIPART_ATTACHMENT + + TEST_CONTENT_MULTIPART_WITH_ATTACHMENT + ) + ).encode("utf-8") + + b"}", + bytearray( + TEST_MESSAGE_MULTIPART_ATTACHMENT + TEST_CONTENT_MULTIPART_WITH_ATTACHMENT + ), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID = ( "OK", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index bdd29f7442b..dc5727991c1 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,6 +1,7 @@ """Test the imap entry initialization.""" import asyncio +from base64 import b64decode from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, call, patch @@ -31,6 +32,7 @@ from .const import ( TEST_FETCH_RESPONSE_MULTIPART_BASE64, TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, + TEST_FETCH_RESPONSE_MULTIPART_WITH_ATTACHMENT, TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -107,20 +109,72 @@ async def test_entry_startup_fails( @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( - ("imap_fetch", "valid_date"), + ("imap_fetch", "valid_date", "parts"), [ - (TEST_FETCH_RESPONSE_TEXT_BARE, True), - (TEST_FETCH_RESPONSE_TEXT_PLAIN, True), - (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True), - (TEST_FETCH_RESPONSE_INVALID_DATE1, False), - (TEST_FETCH_RESPONSE_INVALID_DATE2, False), - (TEST_FETCH_RESPONSE_INVALID_DATE3, False), - (TEST_FETCH_RESPONSE_TEXT_OTHER, True), - (TEST_FETCH_RESPONSE_HTML, True), - (TEST_FETCH_RESPONSE_MULTIPART, True), - (TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, True), - (TEST_FETCH_RESPONSE_MULTIPART_BASE64, True), - (TEST_FETCH_RESPONSE_BINARY, True), + (TEST_FETCH_RESPONSE_TEXT_BARE, True, {}), + (TEST_FETCH_RESPONSE_TEXT_PLAIN, True, {}), + (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True, {}), + (TEST_FETCH_RESPONSE_INVALID_DATE1, False, {}), + (TEST_FETCH_RESPONSE_INVALID_DATE2, False, {}), + (TEST_FETCH_RESPONSE_INVALID_DATE3, False, {}), + (TEST_FETCH_RESPONSE_TEXT_OTHER, True, {}), + (TEST_FETCH_RESPONSE_HTML, True, {}), + ( + TEST_FETCH_RESPONSE_MULTIPART, + True, + { + "0": { + "content_type": "text/plain", + "content_transfer_encoding": "7bit", + }, + "1": {"content_type": "text/html", "content_transfer_encoding": "7bit"}, + }, + ), + ( + TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, + True, + { + "0": { + "content_type": "text/plain", + "content_transfer_encoding": "7bit", + }, + "1": {"content_type": "text/html", "content_transfer_encoding": "7bit"}, + }, + ), + ( + TEST_FETCH_RESPONSE_MULTIPART_BASE64, + True, + { + "0": { + "content_type": "text/plain", + "content_transfer_encoding": "base64", + }, + "1": { + "content_type": "text/html", + "content_transfer_encoding": "base64", + }, + }, + ), + ( + TEST_FETCH_RESPONSE_MULTIPART_WITH_ATTACHMENT, + True, + { + "0,0": { + "content_type": "text/plain", + "content_transfer_encoding": "7bit", + }, + "0,1": { + "content_type": "text/html", + "content_transfer_encoding": "7bit", + }, + "1": { + "content_type": "text/plain", + "filename": "Text attachment content.txt", + "content_transfer_encoding": "base64", + }, + }, + ), + (TEST_FETCH_RESPONSE_BINARY, True, {}), ], ids=[ "bare", @@ -134,13 +188,18 @@ async def test_entry_startup_fails( "multipart", "multipart_empty_plain", "multipart_base64", + "multipart_attachment", "binary", ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize("charset", ["utf-8", "us-ascii"], ids=["utf-8", "us-ascii"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool, charset: str + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + valid_date: bool, + charset: str, + parts: dict[str, Any], ) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") @@ -170,6 +229,7 @@ async def test_receiving_message_successfully( assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["uid"] == "1" + assert data["parts"] == parts assert "Test body" in data["text"] assert (valid_date and isinstance(data["date"], datetime)) or ( not valid_date and data["date"] is None @@ -826,11 +886,33 @@ async def test_enforce_polling( @pytest.mark.parametrize( - ("imap_search", "imap_fetch"), - [(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN)], + ("imap_search", "imap_fetch", "message_parts"), + [ + ( + TEST_SEARCH_RESPONSE, + TEST_FETCH_RESPONSE_MULTIPART_WITH_ATTACHMENT, + { + "0,0": { + "content_type": "text/plain", + "content_transfer_encoding": "7bit", + }, + "0,1": { + "content_type": "text/html", + "content_transfer_encoding": "7bit", + }, + "1": { + "content_type": "text/plain", + "filename": "Text attachment content.txt", + "content_transfer_encoding": "base64", + }, + }, + ) + ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) -async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> None: +async def test_services( + hass: HomeAssistant, mock_imap_protocol: MagicMock, message_parts: dict[str, Any] +) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") @@ -859,6 +941,7 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N assert data["subject"] == "Test subject" assert data["uid"] == "1" assert data["entry_id"] == config_entry.entry_id + assert data["parts"] == message_parts # Test seen service data = {"entry": config_entry.entry_id, "uid": "1"} @@ -889,16 +972,42 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N mock_imap_protocol.store.assert_called_with("1", "+FLAGS (\\Deleted)") mock_imap_protocol.protocol.expunge.assert_called_once() - # Test fetch service + # Test fetch service with text response + mock_imap_protocol.reset_mock() data = {"entry": config_entry.entry_id, "uid": "1"} response = await hass.services.async_call( DOMAIN, "fetch", data, blocking=True, return_response=True ) mock_imap_protocol.fetch.assert_called_with("1", "BODY.PEEK[]") - assert response["text"] == "Test body\r\n" + assert response["text"] == "*Multi* part Test body\n" assert response["sender"] == "john.doe@example.com" assert response["subject"] == "Test subject" assert response["uid"] == "1" + assert response["parts"] == message_parts + + # Test fetch part service with attachment response + mock_imap_protocol.reset_mock() + data = {"entry": config_entry.entry_id, "uid": "1", "part": "1"} + response = await hass.services.async_call( + DOMAIN, "fetch_part", data, blocking=True, return_response=True + ) + mock_imap_protocol.fetch.assert_called_with("1", "BODY.PEEK[]") + assert response["part_data"] == "VGV4dCBhdHRhY2htZW50IGNvbnRlbnQ=\n" + assert response["content_type"] == "text/plain" + assert response["content_transfer_encoding"] == "base64" + assert response["filename"] == "Text attachment content.txt" + assert response["part"] == "1" + assert response["uid"] == "1" + assert b64decode(response["part_data"]) == b"Text attachment content" + + # Test fetch part service with invalid part index + for part in ("A", "2", "0"): + data = {"entry": config_entry.entry_id, "uid": "1", "part": part} + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + DOMAIN, "fetch_part", data, blocking=True, return_response=True + ) + assert exc.value.translation_key == "invalid_part_index" # Test with invalid entry_id data = {"entry": "invalid", "uid": "1"} @@ -943,12 +1052,14 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N ), "delete": ({"entry": config_entry.entry_id, "uid": "1"}, False), "fetch": ({"entry": config_entry.entry_id, "uid": "1"}, True), + "fetch_part": ({"entry": config_entry.entry_id, "uid": "1", "part": "1"}, True), } patch_error_translation_key = { "seen": ("store", "seen_failed"), "move": ("copy", "copy_failed"), "delete": ("store", "delete_failed"), "fetch": ("fetch", "fetch_failed"), + "fetch_part": ("fetch", "fetch_failed"), } for service, (data, response) in service_calls_response.items(): with ( diff --git a/tests/components/imeon_inverter/fixtures/entity_data.json b/tests/components/imeon_inverter/fixtures/entity_data.json index a2717f093f4..2fe9f5ebe66 100644 --- a/tests/components/imeon_inverter/fixtures/entity_data.json +++ b/tests/components/imeon_inverter/fixtures/entity_data.json @@ -1,79 +1,86 @@ { "battery": { - "battery_power": 2500.0, - "battery_soc": 78.0, - "battery_status": "charging", - "battery_stored": 10200.0, - "battery_consumed": 500.0 + "power": 2500.0, + "soc": 78.0, + "status": "charging", + "stored": 10200.0, + "consumed": 500.0 }, "grid": { - "grid_current_l1": 12.5, - "grid_current_l2": 10.8, - "grid_current_l3": 11.2, - "grid_frequency": 50.0, - "grid_voltage_l1": 230.0, - "grid_voltage_l2": 229.5, - "grid_voltage_l3": 230.1 + "current_l1": 12.5, + "current_l2": 10.8, + "current_l3": 11.2, + "frequency": 50.0, + "voltage_l1": 230.0, + "voltage_l2": 229.5, + "voltage_l3": 230.1 }, "input": { - "input_power_l1": 1000.0, - "input_power_l2": 950.0, - "input_power_l3": 980.0, - "input_power_total": 2930.0 + "power_l1": 1000.0, + "power_l2": 950.0, + "power_l3": 980.0, + "power_total": 2930.0 }, "inverter": { - "inverter_charging_current_limit": 50, - "inverter_injection_power_limit": 5000.0, - "manager_inverter_state": "grid_consumption" + "charging_current_limit": 50, + "injection_power_limit": 5000.0 + }, + "manager": { + "inverter_state": "grid_consumption", + "inverter_mode": "smg" }, "meter": { - "meter_power": 2000.0 + "power": 2000.0 }, "output": { - "output_current_l1": 15.0, - "output_current_l2": 14.5, - "output_current_l3": 15.2, - "output_frequency": 49.9, - "output_power_l1": 1100.0, - "output_power_l2": 1080.0, - "output_power_l3": 1120.0, - "output_power_total": 3300.0, - "output_voltage_l1": 231.0, - "output_voltage_l2": 229.8, - "output_voltage_l3": 230.2 + "current_l1": 15.0, + "current_l2": 14.5, + "current_l3": 15.2, + "frequency": 49.9, + "power_l1": 1100.0, + "power_l2": 1080.0, + "power_l3": 1120.0, + "power_total": 3300.0, + "voltage_l1": 231.0, + "voltage_l2": 229.8, + "voltage_l3": 230.2 }, "pv": { - "pv_consumed": 1500.0, - "pv_injected": 800.0, - "pv_power_1": 1200.0, - "pv_power_2": 1300.0, - "pv_power_total": 2500.0 + "consumed": 1500.0, + "injected": 800.0, + "power_1": 1200.0, + "power_2": 1300.0, + "power_total": 2500.0 }, "temp": { - "temp_air_temperature": 25.0, - "temp_component_temperature": 45.5 + "air_temperature": 25.0, + "component_temperature": 45.5 }, "monitoring": { - "monitoring_self_produced": 2600.0, - "monitoring_self_consumption": 85.0, - "monitoring_self_sufficiency": 90.0 + "self_produced": 2600.0, + "self_consumption": 85.0, + "self_sufficiency": 90.0 }, "monitoring_minute": { - "monitoring_minute_building_consumption": 50.0, - "monitoring_minute_grid_consumption": 8.3, - "monitoring_minute_grid_injection": 11.7, - "monitoring_minute_grid_power_flow": -3.4, - "monitoring_minute_solar_production": 43.3 + "building_consumption": 50.0, + "grid_consumption": 8.3, + "grid_injection": 11.7, + "grid_power_flow": -3.4, + "solar_production": 43.3 }, "timeline": { - "timeline_type_msg": "info_bat" + "type_msg": "info_bat" }, "energy": { - "energy_pv": 12000.0, - "energy_grid_injected": 5000.0, - "energy_grid_consumed": 6000.0, - "energy_building_consumption": 15000.0, - "energy_battery_stored": 8000.0, - "energy_battery_consumed": 2000.0 + "pv": 12000.0, + "grid_injected": 5000.0, + "grid_consumed": 6000.0, + "building_consumption": 15000.0, + "battery_stored": 8000.0, + "battery_consumed": 2000.0 + }, + "forecast": { + "cons_remaining_today": 3000.0, + "prod_remaining_today": 7000.0 } } diff --git a/tests/components/imeon_inverter/snapshots/test_select.ambr b/tests/components/imeon_inverter/snapshots/test_select.ambr new file mode 100644 index 00000000000..550402407ac --- /dev/null +++ b/tests/components/imeon_inverter/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_selects[select.imeon_inverter_inverter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart_grid', + 'backup', + 'on_grid', + 'off_grid', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.imeon_inverter_inverter_mode', + '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': 'Inverter mode', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manager_inverter_mode', + 'unique_id': '111111111111111_manager_inverter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.imeon_inverter_inverter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Inverter mode', + 'options': list([ + 'smart_grid', + 'backup', + 'on_grid', + 'off_grid', + ]), + }), + 'context': , + 'entity_id': 'select.imeon_inverter_inverter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'smart_grid', + }) +# --- diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr index 673f561d540..35b51043c73 100644 --- a/tests/components/imeon_inverter/snapshots/test_sensor.ambr +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -52,7 +52,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '25.0', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_consumed-entry] @@ -108,7 +108,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '500.0', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_power-entry] @@ -164,7 +164,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2500.0', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-entry] @@ -217,7 +217,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '78.0', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_status-entry] @@ -277,7 +277,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'charging', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_stored-entry] @@ -333,7 +333,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '10200.0', }) # --- # name: test_sensors[sensor.imeon_inverter_building_consumption-entry] @@ -389,7 +389,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '50.0', }) # --- # name: test_sensors[sensor.imeon_inverter_charging_current_limit-entry] @@ -445,7 +445,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '50', }) # --- # name: test_sensors[sensor.imeon_inverter_component_temperature-entry] @@ -501,7 +501,113 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '45.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_forecast_remaining_energy_consumption_for_today-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.imeon_inverter_forecast_remaining_energy_consumption_for_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Forecast remaining energy consumption for today', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forecast_cons_remaining_today', + 'unique_id': '111111111111111_forecast_cons_remaining_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_forecast_remaining_energy_consumption_for_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Forecast remaining energy consumption for today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_forecast_remaining_energy_consumption_for_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_forecast_remaining_energy_production_for_today-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.imeon_inverter_forecast_remaining_energy_production_for_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Forecast remaining energy production for today', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forecast_prod_remaining_today', + 'unique_id': '111111111111111_forecast_prod_remaining_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_forecast_remaining_energy_production_for_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Forecast remaining energy production for today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_forecast_remaining_energy_production_for_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_consumption-entry] @@ -557,7 +663,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '8.3', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l1-entry] @@ -613,7 +719,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '12.5', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l2-entry] @@ -669,7 +775,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '10.8', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l3-entry] @@ -725,7 +831,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '11.2', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_frequency-entry] @@ -781,7 +887,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '50.0', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_injection-entry] @@ -837,7 +943,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '11.7', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_power_flow-entry] @@ -893,7 +999,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '-3.4', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-entry] @@ -949,7 +1055,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230.0', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-entry] @@ -1005,7 +1111,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '229.5', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-entry] @@ -1061,7 +1167,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230.1', }) # --- # name: test_sensors[sensor.imeon_inverter_injection_power_limit-entry] @@ -1117,7 +1223,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '5000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l1-entry] @@ -1173,7 +1279,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l2-entry] @@ -1229,7 +1335,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '950.0', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l3-entry] @@ -1285,7 +1391,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '980.0', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_total-entry] @@ -1341,7 +1447,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2930.0', }) # --- # name: test_sensors[sensor.imeon_inverter_inverter_state-entry] @@ -1351,10 +1457,11 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'not_connected', 'unsynchronized', 'grid_consumption', 'grid_injection', - 'grid_synchronised_but_not_used', + 'grid_synchronized_but_not_used', ]), }), 'config_entry_id': , @@ -1392,10 +1499,11 @@ 'device_class': 'enum', 'friendly_name': 'Imeon inverter Inverter state', 'options': list([ + 'not_connected', 'unsynchronized', 'grid_consumption', 'grid_injection', - 'grid_synchronised_but_not_used', + 'grid_synchronized_but_not_used', ]), }), 'context': , @@ -1403,7 +1511,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'grid_consumption', }) # --- # name: test_sensors[sensor.imeon_inverter_meter_power-entry] @@ -1459,7 +1567,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l1-entry] @@ -1515,7 +1623,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '15.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l2-entry] @@ -1571,7 +1679,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '14.5', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l3-entry] @@ -1627,7 +1735,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '15.2', }) # --- # name: test_sensors[sensor.imeon_inverter_output_frequency-entry] @@ -1683,7 +1791,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '49.9', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l1-entry] @@ -1739,7 +1847,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1100.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l2-entry] @@ -1795,7 +1903,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1080.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l3-entry] @@ -1851,7 +1959,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1120.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_total-entry] @@ -1907,7 +2015,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '3300.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l1-entry] @@ -1963,7 +2071,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '231.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l2-entry] @@ -2019,7 +2127,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '229.8', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l3-entry] @@ -2075,7 +2183,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230.2', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_consumed-entry] @@ -2131,7 +2239,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1500.0', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_injected-entry] @@ -2187,7 +2295,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '800.0', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_1-entry] @@ -2243,7 +2351,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1200.0', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_2-entry] @@ -2299,7 +2407,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1300.0', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_total-entry] @@ -2355,7 +2463,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2500.0', }) # --- # name: test_sensors[sensor.imeon_inverter_self_consumption-entry] @@ -2410,7 +2518,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '85.0', }) # --- # name: test_sensors[sensor.imeon_inverter_self_sufficiency-entry] @@ -2465,7 +2573,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '90.0', }) # --- # name: test_sensors[sensor.imeon_inverter_solar_production-entry] @@ -2521,7 +2629,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '43.3', }) # --- # name: test_sensors[sensor.imeon_inverter_timeline_status-entry] @@ -2603,7 +2711,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'info_bat', }) # --- # name: test_sensors[sensor.imeon_inverter_today_battery_consumed_energy-entry] @@ -2659,7 +2767,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_battery_stored_energy-entry] @@ -2715,7 +2823,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '8000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_building_consumption-entry] @@ -2771,7 +2879,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '15000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_grid_consumed_energy-entry] @@ -2827,7 +2935,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '6000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_grid_injected_energy-entry] @@ -2883,7 +2991,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '5000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_pv_energy-entry] @@ -2939,6 +3047,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '12000.0', }) # --- diff --git a/tests/components/imeon_inverter/test_select.py b/tests/components/imeon_inverter/test_select.py new file mode 100644 index 00000000000..ca1f73ea0e0 --- /dev/null +++ b/tests/components/imeon_inverter/test_select.py @@ -0,0 +1,55 @@ +"""Test the Imeon Inverter selects.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + SERVICE_SELECT_OPTION, + Platform, +) +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_selects( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Imeon Inverter selects.""" + with patch("homeassistant.components.imeon_inverter.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imeon_inverter: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test select mode updates entity state.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "sensor.imeon_inverter_inverter_mode" + assert entity_id + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "smart_grid"}, + blocking=True, + ) diff --git a/tests/components/imeon_inverter/test_sensor.py b/tests/components/imeon_inverter/test_sensor.py index ec50594f6ba..9e69badea64 100644 --- a/tests/components/imeon_inverter/test_sensor.py +++ b/tests/components/imeon_inverter/test_sensor.py @@ -47,12 +47,12 @@ async def test_sensor_unavailable_on_update_error( exception: Exception, ) -> None: """Test that sensor becomes unavailable when update raises an error.""" - entity_id = "sensor.imeon_inverter_battery_power" - mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + entity_id = "sensor.imeon_inverter_battery_power" + state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 6bd23b272ed..5fe869bee42 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -14,13 +14,8 @@ from homeassistant.components.immich.media_source import ( ImmichMediaView, async_get_media_source, ) -from homeassistant.components.media_player import MediaClass -from homeassistant.components.media_source import ( - BrowseError, - BrowseMedia, - MediaSourceItem, - Unresolvable, -) +from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaClass +from homeassistant.components.media_source import MediaSourceItem, Unresolvable from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked diff --git a/tests/components/insteon/test_api_config.py b/tests/components/insteon/test_api_config.py index 9d38b70c850..bbefb34fe93 100644 --- a/tests/components/insteon/test_api_config.py +++ b/tests/components/insteon/test_api_config.py @@ -68,7 +68,7 @@ async def test_get_modem_schema_hub( ) -> None: """Test getting the Insteon PLM modem configuration schema.""" - ws_client, devices, _, _ = await async_mock_setup( + ws_client, _devices, _, _ = await async_mock_setup( hass, hass_ws_client, config_data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index aeeeeab3d7b..2d15132e5ff 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -494,7 +494,7 @@ async def test_bad_address( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, kpl_properties_data ) -> None: """Test for a bad Insteon address.""" - ws_client, devices = await _setup( + ws_client, _devices = await _setup( hass, hass_ws_client, "33.33.33", kpl_properties_data ) diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 33e71be6dc2..32cedc3c202 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -247,7 +247,7 @@ async def test_failed_connection_plm_manually(hass: HomeAssistant) -> None: result = await _init_form(hass, STEP_PLM) - result2, _ = await _device_form( + _result2, _ = await _device_form( hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM_MANUAL ) result3, _ = await _device_form( diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 50243551d37..b0d98011a17 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -203,6 +203,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "source": "sensor.valid"} ) + hass.config_entries.async_schedule_reload(config_entry.entry_id) await hass.async_block_till_done() # Check that the device association has updated diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index 96d0fa17e63..49ce6b91e96 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -79,7 +79,7 @@ async def test_standard_config_with_single_fireplace_and_bad_credentials( mock_apis_single_fp, ) -> None: """Test bad credentials on a login.""" - mock_local_interface, mock_cloud_interface, mock_fp = mock_apis_single_fp + _mock_local_interface, mock_cloud_interface, _mock_fp = mock_apis_single_fp # Set login error mock_cloud_interface.login_with_credentials.side_effect = LoginError @@ -190,7 +190,7 @@ async def test_dhcp_discovery_non_intellifire_device( """Test successful DHCP Discovery of a non intellifire device..""" # Patch poll with an exception - mock_local_interface, mock_cloud_interface, mock_fp = mock_apis_multifp + mock_local_interface, _mock_cloud_interface, _mock_fp = mock_apis_multifp mock_local_interface.poll.side_effect = ConnectionError result = await hass.config_entries.flow.async_init( diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 3779930e360..1993ebe46e4 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -73,6 +73,32 @@ async def test_http_handle_intent( } +async def test_http_handle_intent_match_failure( + hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser +) -> None: + """Test handle intent match failure via HTTP API.""" + + assert await async_setup_component(hass, "intent", {}) + + hass.states.async_set( + "cover.garage_door_1", "closed", {ATTR_FRIENDLY_NAME: "Garage Door"} + ) + hass.states.async_set( + "cover.garage_door_2", "closed", {ATTR_FRIENDLY_NAME: "Garage Door"} + ) + async_mock_service(hass, "cover", SERVICE_OPEN_COVER) + + client = await hass_client() + resp = await client.post( + "/api/intent/handle", + json={"name": "HassTurnOn", "data": {"name": "Garage Door"}}, + ) + assert resp.status == 200 + data = await resp.json() + + assert "DUPLICATE_NAME" in data["speech"]["plain"]["speech"] + + async def test_cover_intents_loading(hass: HomeAssistant) -> None: """Test Cover Intents Loading.""" assert await async_setup_component(hass, "intent", {}) diff --git a/tests/components/irm_kmi/__init__.py b/tests/components/irm_kmi/__init__.py new file mode 100644 index 00000000000..629c80d5d9e --- /dev/null +++ b/tests/components/irm_kmi/__init__.py @@ -0,0 +1 @@ +"""Tests of IRM KMI integration.""" diff --git a/tests/components/irm_kmi/conftest.py b/tests/components/irm_kmi/conftest.py new file mode 100644 index 00000000000..fe64cdbcd56 --- /dev/null +++ b/tests/components/irm_kmi/conftest.py @@ -0,0 +1,121 @@ +"""Fixtures for the IRM KMI integration tests.""" + +from collections.abc import Generator +import json +from unittest.mock import MagicMock, patch + +from irm_kmi_api import IrmKmiApiError +import pytest + +from homeassistant.components.irm_kmi.const import DOMAIN +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_LOCATION, + CONF_UNIQUE_ID, +) + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Home", + domain=DOMAIN, + data={ + CONF_LOCATION: {ATTR_LATITUDE: 50.84, ATTR_LONGITUDE: 4.35}, + CONF_UNIQUE_ID: "city country", + }, + unique_id="50.84-4.35", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.irm_kmi.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_get_forecast_in_benelux(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something valid and in the Benelux.""" + with patch( + "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + return_value={"cityName": "Brussels", "country": "BE"}, + ): + yield + + +@pytest.fixture +def mock_get_forecast_out_benelux_then_in_belgium(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something outside Benelux.""" + with patch( + "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + side_effect=[ + {"cityName": "Outside the Benelux (Brussels)", "country": "BE"}, + {"cityName": "Brussels", "country": "BE"}, + ], + ): + yield + + +@pytest.fixture +def mock_get_forecast_api_error(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error.""" + with patch( + "homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + side_effect=IrmKmiApiError, + ): + yield + + +@pytest.fixture +def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[MagicMock]: + """Return a mocked IrmKmi api client.""" + fixture: str = "forecast.json" + + forecast = json.loads(load_fixture(fixture, "irm_kmi")) + with patch( + "homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True + ) as irm_kmi_api_mock: + irm_kmi = irm_kmi_api_mock.return_value + irm_kmi.get_forecasts_coord.return_value = forecast + yield irm_kmi + + +@pytest.fixture +def mock_irm_kmi_api_nl(): + """Mock a call to IrmKmiApiClientHa.get_forecasts_coord() to return a forecast in The Netherlands.""" + fixture: str = "forecast_nl.json" + forecast = json.loads(load_fixture(fixture, "irm_kmi")) + with patch( + "homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord", + return_value=forecast, + ): + yield + + +@pytest.fixture +def mock_irm_kmi_api_high_low_temp(): + """Mock a call to IrmKmiApiClientHa.get_forecasts_coord() to return high_low_temp.json forecast.""" + fixture: str = "high_low_temp.json" + forecast = json.loads(load_fixture(fixture, "irm_kmi")) + with patch( + "homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord", + return_value=forecast, + ): + yield + + +@pytest.fixture +def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[MagicMock]: + """Return a mocked IrmKmi api client that will raise an error upon refreshing data.""" + with patch( + "homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True + ) as irm_kmi_api_mock: + irm_kmi = irm_kmi_api_mock.return_value + irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiError + yield irm_kmi diff --git a/tests/components/irm_kmi/fixtures/forecast.json b/tests/components/irm_kmi/fixtures/forecast.json new file mode 100644 index 00000000000..06b8f3d81d7 --- /dev/null +++ b/tests/components/irm_kmi/fixtures/forecast.json @@ -0,0 +1,1474 @@ +{ + "cityName": "Namur", + "country": "BE", + "obs": { + "temp": 7, + "timestamp": "2023-12-26T18:30:00+01:00", + "ww": 15, + "dayNight": "n" + }, + "for": { + "daily": [ + { + "dayName": { + "fr": "Cette nuit", + "nl": "Vannacht", + "en": "Tonight", + "de": "heute abend" + }, + "period": "2", + "day_night": "0", + "dayNight": "n", + "text": { + "nl": "Vanavond verloopt droog, maar geleidelijk neemt ook de middelhoge bewolking toe. Vannacht verschijnen er alsmaar meer lage wolkenvelden. Vooral in de Ardennen kan er wat nevel en mist gevormd worden, waardoor het zicht bij momenten slecht is. Na middernacht begint het licht te regenen vanaf de Franse grens. De minima worden vroeg bereikt en liggen rond 0 of +1 graad op de hoogste toppen en tussen 3 en 6 graden in de meeste andere streken. De zwakke wind uit zuidwest krimpt naar het zuiden tot zuidoosten en wordt aan het einde van de nacht overal matig.", + "fr": "Ce soir, le temps restera sec même si des nuages moyens gagneront également notre territoire. Cette nuit, le ciel finira par se couvrir avec l'arrivée de nuages de plus basse altitude. Principalement en Ardenne, un peu de brume et de brouillard pourra se former, ce qui réduira parfois la visibilité. Après minuit, de faibles pluies se produiront depuis la frontière française. Les minima, atteints rapidement, se situeront autour de 0 ou +1 degré sur le relief et entre +3 et +6 degrés ailleurs. Le vent sera faible de secteur sud-ouest et deviendra le plus souvent modéré en fin de nuit." + }, + "dawnRiseSeconds": "31440", + "dawnSetSeconds": "60120", + "tempMin": 4, + "tempMax": null, + "ww1": 14, + "ww2": 19, + "wwevol": 0, + "ff1": 2, + "ff2": 3, + "ffevol": 0, + "dd": 0, + "ddText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + }, + "wind": { + "speed": 6, + "peakSpeed": null, + "dir": 0, + "dirText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + } + }, + "precipChance": 95, + "precipQuantity": "2" + }, + { + "dayName": { + "fr": "Mercredi", + "nl": "Woensdag", + "en": "Wednesday", + "de": "Mittwoch" + }, + "period": "3", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Foo", + "fr": "Bar", + "en": "Hey!" + }, + "dawnRiseSeconds": "31440", + "dawnSetSeconds": "60180", + "tempMin": 4, + "tempMax": 9, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": null, + "ffevol": null, + "dd": 0, + "ddText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + }, + "wind": { + "speed": 20, + "peakSpeed": "50", + "dir": 0, + "dirText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Jeudi", + "nl": "Donderdag", + "en": "Thursday", + "de": "Donnerstag" + }, + "period": "5", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Donderdag wisselen opklaringen en wolken elkaar af, waaruit plaatselijk enkele buien vallen. Aan het begin van de dag hangt er in de Ardennen veel bewolking met wat laatste lichte regen. Aan het einde van de dag bereiken iets meer buien de kuststreek, om nadien tijdens de daaropvolgende nacht op te schuiven naar het binnenland. Het is vrij winderig en zeer zacht met maxima van 7 graden in de Hoge Ardennen tot 11 graden over het westen van het land. De zuidwestenwind is matig tot vrij krachtig en aan zee soms krachtig met windstoten tot 60 of 70 km/h.", + "fr": "Jeudi, nuages et éclaircies se partageront le ciel avec quelques averses isolées. En début de journée, les nuages pourraient encore s'accrocher sur l'Ardenne avec quelques faibles pluies résiduelles. En fin de journée, des averses un peu plus nombreuses devraient aborder la région littorale, puis traverser notre pays au cours de la nuit suivante. Le temps sera assez venteux et très doux avec des maxima de 7 degrés en haute Ardenne à 11 degrés sur l'ouest du pays. Le vent de sud-ouest sera modéré à assez fort, le long du littoral parfois fort. Les rafales pourront atteindre 60 à 70 km/h." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60180", + "tempMin": 7, + "tempMax": 10, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": 4, + "ffevol": 1, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "60", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Vendredi", + "nl": "Vrijdag", + "en": "Friday", + "de": "Freitag" + }, + "period": "7", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Vrijdag is het zacht en winderig. Bij momenten vallen er intense regenbuien. De maxima klimmen naar waarden tussen 6 en 10 graden bij een vrij krachtige en aan zee soms krachtige zuidwestenwind. Rukwinden zijn mogelijk tot 60 of 70 km/h.", + "fr": "Vendredi, le temps sera doux et venteux. De nouvelles pluies parfois importantes et sous forme d'averses traverseront notre pays. Les maxima varieront entre 6 et 10 degrés avec un vent assez fort de sud-ouest, le long du littoral parfois fort. Les rafales atteindront 60 à 70 km/h." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60240", + "tempMin": 8, + "tempMax": 9, + "ww1": 6, + "ww2": 19, + "wwevol": 0, + "ff1": 5, + "ff2": null, + "ffevol": null, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "65", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "8" + }, + { + "dayName": { + "fr": "Samedi", + "nl": "Zaterdag", + "en": "Saturday", + "de": "Samstag" + }, + "period": "9", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zaterdag wordt het onstabieler. We krijgen bewolkte perioden te verwerken, die soms plaats maken voor enkele zonnige momenten. Het wordt droger, maar toch blijven enkele buien nog steeds mogelijk. De maxima liggen tussen 5 en 9 graden. De westen- tot zuidwestenwind neemt tijdelijk af in kracht, maar blijft in de kustregio vrij krachtig waaien.", + "fr": "Samedi, nous passerons sous un régime plus variable où les passages nuageux laisseront par moments entrevoir quelques rayons de soleil. Il fera plus sec mais quelques averses resteront encore possibles. Les maxima varieront entre 5 et 9 degrés. Le vent d'ouest à sud-ouest diminuera temporairement mais restera encore assez soutenu le long du littoral." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60300", + "tempMin": 4, + "tempMax": 8, + "ww1": 1, + "ww2": 15, + "wwevol": 0, + "ff1": 4, + "ff2": 5, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 20, + "peakSpeed": "55", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 50, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Dimanche", + "nl": "Zondag", + "en": "Sunday", + "de": "Sonntag" + }, + "period": "11", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zondag trekt een nieuwe actieve regenzone over ons land. Deze wordt aangedreven door een krachtige zuidwestenwind. Na zijn doortocht draait de wind naar het noordwesten en wordt het frisser en onstabieler met buien. We halen maxima van 6 tot 10 graden.", + "fr": "Dimanche, une nouvelle zone de pluie active traversera notre pays, poussée par un vigoureux vent de sud-ouest. Après son passage, le vent basculera au nord-ouest et de l'air plus frais et plus instable accompagné d'averses envahira notre pays. Les maxima varieront entre 6 et 10 degrés." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60360", + "tempMin": 8, + "tempMax": 8, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 6, + "ff2": 5, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "85", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "9" + }, + { + "dayName": { + "fr": "Lundi", + "nl": "Maandag", + "en": "Monday", + "de": "Montag" + }, + "period": "13", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Op Nieuwjaarsdag lijkt het rustiger te worden met minder regen en minder wind. Het wordt iets frisser met maxima tussen 3 en 7 graden.", + "fr": "Le jour de l'an devrait connaître une accalmie passagère avec moins de pluie et de vent. Il fera un peu plus frais avec des maxima de 3 à 7 degrés." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60420", + "tempMin": 5, + "tempMax": 6, + "ww1": 18, + "ww2": 19, + "wwevol": 0, + "ff1": 1, + "ff2": 3, + "ffevol": 0, + "dd": 315, + "ddText": { + "fr": "SE", + "nl": "ZO", + "en": "SE", + "de": "SO" + }, + "wind": { + "speed": 12, + "peakSpeed": null, + "dir": 315, + "dirText": { + "fr": "SE", + "nl": "ZO", + "en": "SE", + "de": "SO" + } + }, + "precipChance": 80, + "precipQuantity": "3" + }, + { + "dayName": { + "fr": "Mardi", + "nl": "Dinsdag", + "en": "Tuesday", + "de": "Dienstag" + }, + "period": "15", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Volgende week dinsdag trekt een nieuwe regenzone over ons land. De maxima liggen tussen 5 en 9 graden.", + "fr": "Mardi prochain, une nouvelle zone de pluie devrait traverser notre pays. Les maxima varieront entre 5 et 9 degrés." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60480", + "tempMin": 5, + "tempMax": 9, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "75", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "7" + } + ], + "showWarningTab": false, + "graph": { + "svg": [ + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 1.3638709677419354 + } + ] + }, + "hourly": [ + { + "hour": "18", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1020, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 6, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 5, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 4, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 4, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1021, + "windSpeedKm": 5, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 5, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 10, + "windPeakSpeedKm": null, + "windDirection": 338, + "windDirectionText": { + "nl": "ZZO", + "fr": "SSE", + "en": "SSE", + "de": "SSO" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 10, + "windPeakSpeedKm": null, + "windDirection": 338, + "windDirectionText": { + "nl": "ZZO", + "fr": "SSE", + "en": "SSE", + "de": "SSO" + }, + "dayNight": "n", + "dateShow": "27/12", + "dateShowLocalized": { + "nl": "Woe.", + "fr": "Mer.", + "en": "Wed.", + "de": "Mit." + } + }, + { + "hour": "01", + "temp": 8, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.01, + "pressure": 1020, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 8, + "ww": "18", + "precipChance": "70", + "precipQuantity": 0.98, + "pressure": 1020, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 8, + "ww": "18", + "precipChance": "90", + "precipQuantity": 1.14, + "pressure": 1019, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 9, + "ww": "18", + "precipChance": "70", + "precipQuantity": 0.15, + "pressure": 1019, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 9, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0, + "pressure": 1018, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 9, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1018, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 9, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1018, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 7, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1016, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1015, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": 50, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "18", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 8, + "ww": "3", + "precipChance": "20", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 8, + "ww": "3", + "precipChance": "40", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 35, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 8, + "ww": "6", + "precipChance": "40", + "precipQuantity": 0.11, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 8, + "ww": "18", + "precipChance": "40", + "precipQuantity": 0.21, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 8, + "ww": "15", + "precipChance": "40", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 8, + "ww": "15", + "precipChance": "20", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n", + "dateShow": "28/12", + "dateShowLocalized": { + "nl": "Don.", + "fr": "Jeu.", + "en": "Thu.", + "de": "Don." + } + }, + { + "hour": "01", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 8, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 7, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 7, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 7, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 8, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1014, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 8, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0.02, + "pressure": 1014, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 35, + "windPeakSpeedKm": 60, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "18", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + } + ], + "warning": [] + }, + "module": [ + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=pollen&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=pollen&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=pollen&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=pollen&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 3.0458333333333334 + } + }, + { + "type": "uv", + "data": { + "levelValue": 0.7, + "level": { + "nl": "Laag", + "fr": "Faible", + "en": "Low", + "de": "Niedrig" + }, + "title": { + "nl": "Uv-index", + "fr": "Indice UV", + "en": "UV Index", + "de": "UV Index" + } + } + }, + { + "type": "observation", + "data": { + "count": 690, + "title": { + "nl": "Waarnemingen vandaag", + "fr": "Observations d'aujourd'hui", + "en": "Today's Observations", + "de": "Beobachtungen heute" + } + } + }, + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=nl&k=782832cc606de3bad9b7f2002de4b4b1", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=fr&k=782832cc606de3bad9b7f2002de4b4b1", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=en&k=782832cc606de3bad9b7f2002de4b4b1", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=de&k=782832cc606de3bad9b7f2002de4b4b1" + }, + "ratio": 1.6587926509186353 + } + } + ], + "animation": { + "localisationLayer": "https://app.meteo.be/services/appv4/?s=getLocalizationLayerBE&ins=92094&f=2&k=2c886c51e74b671c8fc3865f4a0e9318", + "localisationLayerRatioX": 0.6667, + "localisationLayerRatioY": 0.523, + "speed": 0.3, + "type": "10min", + "unit": { + "fr": "mm/10min", + "nl": "mm/10min", + "en": "mm/10min", + "de": "mm/10min" + }, + "country": "BE", + "sequence": [ + { + "time": "2023-12-26T17:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261610&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261620&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261630&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261640&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261650&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0.1, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T17:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261700&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0.01, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261710&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0.12, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261720&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 1.2, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261730&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 2, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261740&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-26T18:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312261750&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + } + ], + "threshold": [], + "sequenceHint": { + "nl": "Geen regen voorzien op korte termijn", + "fr": "Pas de pluie prévue prochainement", + "en": "No rain forecasted shortly", + "de": "Kein Regen erwartet in naher Zukunft" + } + }, + "todayObsCount": 690 +} diff --git a/tests/components/irm_kmi/fixtures/forecast_nl.json b/tests/components/irm_kmi/fixtures/forecast_nl.json new file mode 100644 index 00000000000..452ba581cc0 --- /dev/null +++ b/tests/components/irm_kmi/fixtures/forecast_nl.json @@ -0,0 +1,1355 @@ +{ + "cityName": "Lelystad", + "country": "NL", + "obs": { + "ww": 15, + "municipality_code": "0995", + "temp": 11, + "windSpeedKm": 40, + "timestamp": "2023-12-28T14:30:00+00:00", + "windDirection": 45, + "municipality": "Lelystad", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + }, + "dayNight": "d" + }, + "for": { + "daily": [ + { + "dayName": { + "nl": "Vandaag", + "fr": "Aujourd'hui", + "de": "Heute", + "en": "Today" + }, + "timestamp": "2023-12-28T12:00:00+00:00", + "text": { + "nl": "Waarschuwingen \nVanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).\n\nVanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.\nVanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.\nVanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.\n\nKomende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.\n\nMorgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.\nMorgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.\nMorgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. \n(Bron: KNMI, 2023-12-28T06:56:00+01:00)\n", + "en": "Waarschuwingen \nVanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).\n\nVanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.\nVanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.\nVanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.\n\nKomende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.\n\nMorgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.\nMorgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.\nMorgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. \n(Bron: KNMI, 2023-12-28T06:56:00+01:00)\n", + "fr": "Waarschuwingen \nVanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).\n\nVanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.\nVanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.\nVanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.\n\nKomende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.\n\nMorgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.\nMorgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.\nMorgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. \n(Bron: KNMI, 2023-12-28T06:56:00+01:00)\n", + "de": "Waarschuwingen \nVanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).\n\nVanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.\nVanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.\nVanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.\n\nKomende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.\n\nMorgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.\nMorgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.\nMorgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. \n(Bron: KNMI, 2023-12-28T06:56:00+01:00)\n" + }, + "dayNight": "d", + "tempMin": null, + "tempMax": 11, + "ww1": 4, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "windSpeedKm": 32, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 32, + "peakSpeed": 33, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 0.1, + "uvIndex": 1, + "sunRiseUtc": 28063, + "sunSetUtc": 56046, + "sunRise": 31663, + "sunSet": 59646 + }, + { + "dayName": { + "nl": "Vannacht", + "fr": "Cette nuit", + "de": "Heute abend", + "en": "Tonight" + }, + "timestamp": "2023-12-29T00:00:00+00:00", + "dayNight": "n", + "tempMin": 9, + "tempMax": null, + "ww1": 15, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "windSpeedKm": 31, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 31, + "peakSpeed": 32, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 3, + "uvIndex": null, + "sunRiseUtc": null, + "sunSetUtc": null, + "sunRise": null, + "sunSet": null + }, + { + "dayName": { + "nl": "Morgen", + "fr": "Demain", + "de": "Morgen", + "en": "Tomorrow" + }, + "timestamp": "2023-12-29T12:00:00+00:00", + "dayNight": "d", + "tempMin": null, + "tempMax": 10, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "windSpeedKm": 26, + "dd": 68, + "ddText": { + "nl": "WZW", + "fr": "OSO", + "de": "WSW", + "en": "WSW" + }, + "wind": { + "speed": 26, + "peakSpeed": 28, + "dir": 68, + "dirText": { + "nl": "WZW", + "fr": "OSO", + "de": "WSW", + "en": "WSW" + } + }, + "precipChance": null, + "precipQuantity": 3.8, + "uvIndex": 1, + "sunRiseUtc": 28068, + "sunSetUtc": 56100, + "sunRise": 31668, + "sunSet": 59700 + }, + { + "dayName": { + "nl": "Zaterdag", + "fr": "Samedi", + "de": "Samstag", + "en": "Saturday" + }, + "timestamp": "2023-12-30T12:00:00+00:00", + "dayNight": "d", + "tempMin": 5, + "tempMax": 10, + "ww1": 16, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": null, + "ffevol": null, + "windSpeedKm": 22, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 22, + "peakSpeed": 25, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 1.7, + "uvIndex": 1, + "sunRiseUtc": 28069, + "sunSetUtc": 56157, + "sunRise": 31669, + "sunSet": 59757 + }, + { + "dayName": { + "nl": "Zondag", + "fr": "Dimanche", + "de": "Sonntag", + "en": "Sunday" + }, + "timestamp": "2023-12-31T12:00:00+00:00", + "dayNight": "d", + "tempMin": 7, + "tempMax": 9, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "windSpeedKm": 30, + "dd": 23, + "ddText": { + "nl": "ZZW", + "fr": "SSO", + "de": "SSW", + "en": "SSW" + }, + "wind": { + "speed": 30, + "peakSpeed": 31, + "dir": 23, + "dirText": { + "nl": "ZZW", + "fr": "SSO", + "de": "SSW", + "en": "SSW" + } + }, + "precipChance": null, + "precipQuantity": 4.2, + "uvIndex": 1, + "sunRiseUtc": 28067, + "sunSetUtc": 56216, + "sunRise": 31667, + "sunSet": 59816 + }, + { + "dayName": { + "nl": "Maandag", + "fr": "Lundi", + "de": "Montag", + "en": "Monday" + }, + "timestamp": "2024-01-01T12:00:00+00:00", + "dayNight": "d", + "tempMin": 5, + "tempMax": 7, + "ww1": 16, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": null, + "ffevol": null, + "windSpeedKm": 23, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 23, + "peakSpeed": 28, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 2.2, + "uvIndex": 1, + "sunRiseUtc": 28062, + "sunSetUtc": 56279, + "sunRise": 31662, + "sunSet": 59879 + }, + { + "dayName": { + "nl": "Dinsdag", + "fr": "Mardi", + "de": "Dienstag", + "en": "Tuesday" + }, + "timestamp": "2024-01-02T12:00:00+00:00", + "dayNight": "d", + "tempMin": 3, + "tempMax": 6, + "ww1": 16, + "ww2": null, + "wwevol": null, + "ff1": 3, + "ff2": null, + "ffevol": null, + "windSpeedKm": 15, + "dd": 45, + "ddText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + }, + "wind": { + "speed": 15, + "peakSpeed": 16, + "dir": 45, + "dirText": { + "nl": "ZW", + "fr": "SO", + "de": "SW", + "en": "SW" + } + }, + "precipChance": null, + "precipQuantity": 1.4, + "uvIndex": 1, + "sunRiseUtc": 28052, + "sunSetUtc": 56344, + "sunRise": 31652, + "sunSet": 59944 + }, + { + "dayName": { + "nl": "Woensdag", + "fr": "Mercredi", + "de": "Mittwoch", + "en": "Wednesday" + }, + "timestamp": "2024-01-03T12:00:00+00:00", + "dayNight": "d", + "tempMin": 3, + "tempMax": 6, + "ww1": 16, + "ww2": null, + "wwevol": null, + "ff1": 3, + "ff2": null, + "ffevol": null, + "windSpeedKm": 13, + "dd": 23, + "ddText": { + "nl": "ZZW", + "fr": "SSO", + "de": "SSW", + "en": "SSW" + }, + "wind": { + "speed": 13, + "peakSpeed": 14, + "dir": 23, + "dirText": { + "nl": "ZZW", + "fr": "SSO", + "de": "SSW", + "en": "SSW" + } + }, + "precipChance": null, + "precipQuantity": 1, + "uvIndex": 1, + "sunRiseUtc": 28040, + "sunSetUtc": 56412, + "sunRise": 31640, + "sunSet": 60012 + } + ], + "showWarningTab": false, + "graph": { + "svg": [ + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tx&l=nl&k=353efbb53695c7207f520b00303e716a", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tx&l=fr&k=353efbb53695c7207f520b00303e716a", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tx&l=en&k=353efbb53695c7207f520b00303e716a", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tx&l=de&k=353efbb53695c7207f520b00303e716a" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tn&l=nl&k=353efbb53695c7207f520b00303e716a", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tn&l=fr&k=353efbb53695c7207f520b00303e716a", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tn&l=en&k=353efbb53695c7207f520b00303e716a", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=tn&l=de&k=353efbb53695c7207f520b00303e716a" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=rr&l=nl&k=353efbb53695c7207f520b00303e716a", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=rr&l=fr&k=353efbb53695c7207f520b00303e716a", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=rr&l=en&k=353efbb53695c7207f520b00303e716a", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=200995&e=rr&l=de&k=353efbb53695c7207f520b00303e716a" + }, + "ratio": 1.3638709677419354 + } + ] + }, + "hourly": [ + { + "hourUtc": "14", + "hour": "15", + "temp": 10, + "windSpeedKm": 33, + "dayNight": "d", + "ww": "15", + "pressure": "1008", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "15", + "hour": "16", + "temp": 10, + "windSpeedKm": 32, + "dayNight": "d", + "ww": "15", + "pressure": "1008", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "16", + "hour": "17", + "temp": 10, + "windSpeedKm": 32, + "dayNight": "n", + "ww": "15", + "pressure": "1007", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "17", + "hour": "18", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1007", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "18", + "hour": "19", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "19", + "hour": "20", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "20", + "hour": "21", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "21", + "hour": "22", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "16", + "pressure": "1006", + "precipQuantity": 0.7, + "precipChance": "70", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "22", + "hour": "23", + "temp": 10, + "windSpeedKm": 37, + "dayNight": "n", + "ww": "16", + "pressure": "1006", + "precipQuantity": 0.1, + "precipChance": "10", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "23", + "hour": "00", + "temp": 10, + "dateShowLocalized": { + "fr": "Ven.", + "en": "Fri.", + "nl": "Vri.", + "de": "Fre." + }, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "15", + "pressure": "1006", + "precipQuantity": 0, + "dateShow": "29/12", + "precipChance": "20", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "00", + "hour": "01", + "temp": 10, + "windSpeedKm": 31, + "dayNight": "n", + "ww": "16", + "pressure": "1005", + "precipQuantity": 1.9, + "precipChance": "80", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "01", + "hour": "02", + "temp": 10, + "windSpeedKm": 38, + "dayNight": "n", + "ww": "16", + "pressure": "1005", + "precipQuantity": 0.6, + "precipChance": "70", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "02", + "hour": "03", + "temp": 10, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "03", + "hour": "04", + "temp": 10, + "windSpeedKm": 34, + "dayNight": "n", + "ww": "15", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "04", + "hour": "05", + "temp": 9, + "windSpeedKm": 35, + "dayNight": "n", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "05", + "hour": "06", + "temp": 9, + "windSpeedKm": 34, + "dayNight": "n", + "ww": "15", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "06", + "hour": "07", + "temp": 9, + "windSpeedKm": 32, + "dayNight": "n", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "07", + "hour": "08", + "temp": 9, + "windSpeedKm": 31, + "dayNight": "n", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "08", + "hour": "09", + "temp": 9, + "windSpeedKm": 31, + "dayNight": "d", + "ww": "3", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "09", + "hour": "10", + "temp": 9, + "windSpeedKm": 32, + "dayNight": "d", + "ww": "15", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "10", + "hour": "11", + "temp": 10, + "windSpeedKm": 32, + "dayNight": "d", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "11", + "hour": "12", + "temp": 10, + "windSpeedKm": 34, + "dayNight": "d", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "12", + "hour": "13", + "temp": 10, + "windSpeedKm": 33, + "dayNight": "d", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "13", + "hour": "14", + "temp": 10, + "windSpeedKm": 31, + "dayNight": "d", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "14", + "hour": "15", + "temp": 10, + "windSpeedKm": 28, + "dayNight": "d", + "ww": "0", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "15", + "hour": "16", + "temp": 9, + "windSpeedKm": 24, + "dayNight": "d", + "ww": "0", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "16", + "hour": "17", + "temp": 8, + "windSpeedKm": 20, + "dayNight": "n", + "ww": "0", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "17", + "hour": "18", + "temp": 8, + "windSpeedKm": 18, + "dayNight": "n", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + }, + { + "hourUtc": "18", + "hour": "19", + "temp": 8, + "windSpeedKm": 15, + "dayNight": "n", + "ww": "15", + "pressure": "1005", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "23", + "windDirectionText": { + "fr": "SSO", + "en": "SSW", + "nl": "ZZW", + "de": "SSW" + } + }, + { + "hourUtc": "19", + "hour": "20", + "temp": 8, + "windSpeedKm": 22, + "dayNight": "n", + "ww": "16", + "pressure": "1005", + "precipQuantity": 5.7, + "precipChance": "100", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "20", + "hour": "21", + "temp": 7, + "windSpeedKm": 26, + "dayNight": "n", + "ww": "6", + "pressure": "1006", + "precipQuantity": 3.8, + "precipChance": "100", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "21", + "hour": "22", + "temp": 8, + "windSpeedKm": 24, + "dayNight": "n", + "ww": "3", + "pressure": "1006", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "22", + "hour": "23", + "temp": 7, + "windSpeedKm": 22, + "dayNight": "n", + "ww": "15", + "pressure": "1007", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "23", + "hour": "00", + "temp": 8, + "dateShowLocalized": { + "fr": "Sam.", + "en": "Sat.", + "nl": "Zat.", + "de": "Sam." + }, + "windSpeedKm": 26, + "dayNight": "n", + "ww": "3", + "pressure": "1008", + "precipQuantity": 0, + "dateShow": "30/12", + "precipChance": "0", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "00", + "hour": "01", + "temp": 7, + "windSpeedKm": 26, + "dayNight": "n", + "ww": "0", + "pressure": "1007", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "01", + "hour": "02", + "temp": 7, + "windSpeedKm": 24, + "dayNight": "n", + "ww": "0", + "pressure": "1008", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "02", + "hour": "03", + "temp": 7, + "windSpeedKm": 24, + "dayNight": "n", + "ww": "3", + "pressure": "1008", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "90", + "windDirectionText": { + "fr": "O", + "en": "W", + "nl": "W", + "de": "W" + } + }, + { + "hourUtc": "03", + "hour": "04", + "temp": 7, + "windSpeedKm": 23, + "dayNight": "n", + "ww": "0", + "pressure": "1009", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "04", + "hour": "05", + "temp": 6, + "windSpeedKm": 23, + "dayNight": "n", + "ww": "0", + "pressure": "1009", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "05", + "hour": "06", + "temp": 6, + "windSpeedKm": 21, + "dayNight": "n", + "ww": "3", + "pressure": "1009", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "06", + "hour": "07", + "temp": 6, + "windSpeedKm": 20, + "dayNight": "n", + "ww": "3", + "pressure": "1010", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "07", + "hour": "08", + "temp": 6, + "windSpeedKm": 17, + "dayNight": "n", + "ww": "3", + "pressure": "1011", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "08", + "hour": "09", + "temp": 6, + "windSpeedKm": 13, + "dayNight": "d", + "ww": "0", + "pressure": "1011", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "68", + "windDirectionText": { + "fr": "OSO", + "en": "WSW", + "nl": "WZW", + "de": "WSW" + } + }, + { + "hourUtc": "09", + "hour": "10", + "temp": 5, + "windSpeedKm": 12, + "dayNight": "d", + "ww": "3", + "pressure": "1012", + "precipQuantity": 0, + "precipChance": "0", + "windDirection": "45", + "windDirectionText": { + "fr": "SO", + "en": "SW", + "nl": "ZW", + "de": "SW" + } + } + ], + "warning": [] + }, + "module": [ + { + "type": "uv", + "data": { + "levelValue": 1, + "level": { + "nl": "Laag", + "fr": "Faible", + "en": "Low", + "de": "Niedrig" + }, + "title": { + "nl": "Uv-index", + "fr": "Indice UV", + "en": "UV Index", + "de": "UV Index" + } + } + }, + { + "type": "observation", + "data": { + "count": 480, + "title": { + "nl": "Waarnemingen vandaag", + "fr": "Observations d'aujourd'hui", + "en": "Today's Observations", + "de": "Beobachtungen heute" + } + } + }, + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem_KNMI&l=nl&k=353efbb53695c7207f520b00303e716a", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem_KNMI&l=fr&k=353efbb53695c7207f520b00303e716a", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem_KNMI&l=en&k=353efbb53695c7207f520b00303e716a", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem_KNMI&l=de&k=353efbb53695c7207f520b00303e716a" + }, + "ratio": 1.6587926509186353 + } + } + ], + "animation": { + "localisationLayer": "https://app.meteo.be/services/appv4/?s=getLocalizationLayerNL&ins=200995&f=2&k=9145c16494963cfccf2854556ee8bf52", + "localisationLayerRatioX": 0.5716, + "localisationLayerRatioY": 0.3722, + "speed": 0.3, + "type": "5min", + "unit": { + "fr": "mm/h", + "nl": "mm/h", + "en": "mm/h", + "de": "mm/Std" + }, + "country": "NL", + "sequence": [ + { + "time": "2023-12-28T13:50:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281350_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T13:55:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281355_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:00:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281400_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:05:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281405_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:10:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281410_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:15:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281415_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:20:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281420_640.png", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-28T14:25:00+00:00", + "uri": "https://cdn.knmi.nl/knmi/map/page/weer/actueel-weer/neerslagradar/weerapp/RAD_NL25_PCP_CM_202312281425_640.png", + "value": 0.15, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + } + ], + "threshold": [], + "sequenceHint": { + "nl": "Geen regen voorzien op korte termijn", + "fr": "Pas de pluie prévue prochainement", + "en": "No rain forecasted shortly", + "de": "Kein Regen erwartet in naher Zukunft" + } + }, + "todayObsCount": 480 +} diff --git a/tests/components/irm_kmi/fixtures/forecast_out_of_benelux.json b/tests/components/irm_kmi/fixtures/forecast_out_of_benelux.json new file mode 100644 index 00000000000..a2b2a805e2c --- /dev/null +++ b/tests/components/irm_kmi/fixtures/forecast_out_of_benelux.json @@ -0,0 +1,1625 @@ +{ + "cityName": "Hors de Belgique (Bxl)", + "country": "BE", + "obs": { + "temp": 9, + "timestamp": "2023-12-27T11:20:00+01:00", + "ww": 15, + "dayNight": "d" + }, + "for": { + "daily": [ + { + "dayName": { + "fr": "Mercredi", + "nl": "Woensdag", + "en": "Wednesday", + "de": "Mittwoch" + }, + "period": "1", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Deze ochtend start de dag betrokken met lichte regen op de meeste plaatsen. In de voormiddag verlaat de zwakke regenzone ons land via Nederland. In de namiddag blijft het droog en wordt het vrij zonnig met soms wat meer hoge sluierwolken. De maxima liggen tussen 5 en 8 graden in het zuiden van het land en rond 9 of 10 graden in het centrum en aan zee. De matige zuidenwind ruimt naar zuidzuidwest en wordt vrij krachtig tot lokaal krachtig aan zee. Vooral in de kuststreek en op het Ardense reliëf zijn er windstoten mogelijk rond 50 km/h.", + "fr": "Ce matin, la journée débutera sous les nuages et de faibles pluies en de nombreux endroits. En matinée, cette zone de précipitations affaiblies quittera notre pays pour les Pays-Bas. L'après-midi, le temps restera sec et assez ensoleillé même si le soleil sera parfois masqué par des champs de nuages élevés. Les maxima seront compris entre 5 et 8 degrés dans le sud et proches de 9 ou 10 degrés en dans le centre et à la mer. Le vent modéré de sud virera au sud-sud-ouest et deviendra assez fort, à parfois fort au littoral. Des rafales de 50 km/h pourront se produire, essentiellement à la côte et sur les hauteurs de l'Ardenne." + }, + "dawnRiseSeconds": "31440", + "dawnSetSeconds": "60180", + "tempMin": null, + "tempMax": 10, + "ww1": 14, + "ww2": 3, + "wwevol": 0, + "ff1": 4, + "ff2": null, + "ffevol": null, + "dd": 22, + "ddText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + }, + "wind": { + "speed": 20, + "peakSpeed": null, + "dir": 22, + "dirText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Cette nuit", + "nl": "Vannacht", + "en": "Tonight", + "de": "heute abend" + }, + "period": "2", + "day_night": "0", + "dayNight": "n", + "text": { + "nl": "Vanavond en vannacht trekt een volgende (zwakke) storing door het land van west naar oost met wat regen of enkele buien. Aan de achterzijde van deze storing klaart het uit. Tegen het einde van de nacht verlaat de regezone stilaan ons land via het zuidoosten. De minima liggen tussen 4 en 9 graden. Er staat een matige tot vrij krachtige zuidwestenwind met rukwinden tot 60 km/h.", + "fr": "Ce soir et cette nuit, une (faible) perturbation traversera le pays d'ouest en est avec un peu de pluie ou quelques averses. A l'arrière, le ciel se dégagera. A l'aube, le zone de précipitations quittera progressivement le pays par le sud-est. Les minima varieront de 4 à 9 degrés, sous un vent modéré à assez fort de sud-ouest. Les rafales pourront atteindre des valeurs de 60 km/h." + }, + "dawnRiseSeconds": "31440", + "dawnSetSeconds": "60180", + "tempMin": 9, + "tempMax": null, + "ww1": 6, + "ww2": 3, + "wwevol": 0, + "ff1": 5, + "ff2": 4, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "55", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "1" + }, + { + "dayName": { + "fr": "Jeudi", + "nl": "Donderdag", + "en": "Thursday", + "de": "Donnerstag" + }, + "period": "3", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Morgen wisselen opklaringen en wolken elkaar af, waaruit plaatselijk enkele buien kunnen vallen. Aan het begin van de dag hangt er in de Ardennen veel lage bewolking. Het is vrij winderig en zeer zacht met maxima van 7 graden in de Hoge Ardennen tot 11 graden over het westen van het land. De zuidwestenwind is matig tot vrij krachtig met windstoten tot 65 km/h.", + "fr": "Demain, nuages et éclaircies se partageront le ciel avec quelques averses isolées. En début de journée, les nuages bas pourraient encore s'accrocher sur l'Ardenne. Le temps sera assez venteux et très doux avec des maxima de 7 degrés en Haute Ardenne à 11 degrés sur l'ouest du pays. Le vent de sud-ouest sera modéré à assez fort, avec des rafales jusqu'à 65 km/h." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60180", + "tempMin": 9, + "tempMax": 11, + "ww1": 1, + "ww2": 3, + "wwevol": 0, + "ff1": 5, + "ff2": 4, + "ffevol": 1, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "60", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Vendredi", + "nl": "Vrijdag", + "en": "Friday", + "de": "Freitag" + }, + "period": "5", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Vrijdag is het wisselvallig en winderig. Bij momenten vallen er intense regenbuien. De maxima klimmen naar waarden tussen 7 en 11 graden bij een vrij krachtige zuidwestenwind. Er zijn rukwinden mogelijk tot 70 km/h.", + "fr": "Vendredi, le temps sera variable, doux et venteux. De nouvelles pluies parfois abondantes et sous forme d'averses traverseront notre pays. Les maxima varieront entre 7 et 11 degrés avec un vent assez fort de sud-ouest. Les rafales pourront atteindre 70 km/h." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60240", + "tempMin": 9, + "tempMax": 10, + "ww1": 6, + "ww2": 3, + "wwevol": 0, + "ff1": 5, + "ff2": 4, + "ffevol": 1, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "55", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "1" + }, + { + "dayName": { + "fr": "Samedi", + "nl": "Zaterdag", + "en": "Saturday", + "de": "Samstag" + }, + "period": "7", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zaterdagvoormiddag is het vaak droog met tijdelijk opklaringen. In de loop van de dag neemt de bewolking toe, gevolgd door regen vanuit het westen. De maxima schommelen tussen 5 en 10 graden. De wind wordt vrij krachtig en krachtig aan zee uit zuidwest.", + "fr": "Samedi matin, le temps sera souvent sec avec temporairement des éclaircies. Dans le courant de la journée, la nébulosité augmentera, et sera suivie de pluies depuis l'ouest. Les maxima varieront entre 5 et 10 degrés. Le vent de sud-ouest sera assez fort, à fort le long du littoral." + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60300", + "tempMin": 4, + "tempMax": 8, + "ww1": 1, + "ww2": 15, + "wwevol": 0, + "ff1": 3, + "ff2": 4, + "ffevol": 0, + "dd": 22, + "ddText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + }, + "wind": { + "speed": 20, + "peakSpeed": null, + "dir": 22, + "dirText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Dimanche", + "nl": "Zondag", + "en": "Sunday", + "de": "Sonntag" + }, + "period": "9", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zondagochtend verlaat een actieve regenzone ons land via het zuidoosten. Daarachter wordt het wisselvallig met buien. De maxima schommelen tussen 5 en 8 graden. De wind is vrij krachtig en ruimt van zuidwest naar west.", + "fr": "Dimanche matin, une zone de pluie active finira de traverser notre pays et le quittera rapidement par le sud-est. A l'arrière, on retrouvera un temps variable avec des averses. Les maxima varieront entre 5 et 8 degrés. Le vent sera assez fort et virera du sud-ouest à l'ouest. " + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60360", + "tempMin": 7, + "tempMax": 9, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": 6, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 39, + "peakSpeed": "90", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "14" + }, + { + "dayName": { + "fr": "Lundi", + "nl": "Maandag", + "en": "Monday", + "de": "Montag" + }, + "period": "11", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Maandag blijft het overwegend droog met tijdelijk brede opklaringen. De maxima schommelen tussen 3 en 7 graden.", + "fr": "Lundi, le temps restera généralement sec avec temporairement de larges éclaircies. Les maxima varieront entre 3 et 7 degrés. " + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60420", + "tempMin": 3, + "tempMax": 6, + "ww1": 6, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": null, + "ffevol": null, + "dd": 67, + "ddText": { + "fr": "OSO", + "nl": "WZW", + "en": "WSW", + "de": "WSW" + }, + "wind": { + "speed": 29, + "peakSpeed": "65", + "dir": 67, + "dirText": { + "fr": "OSO", + "nl": "WZW", + "en": "WSW", + "de": "WSW" + } + }, + "precipChance": 100, + "precipQuantity": "3" + }, + { + "dayName": { + "fr": "Mardi", + "nl": "Dinsdag", + "en": "Tuesday", + "de": "Dienstag" + }, + "period": "13", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Dinsdag komt er opnieuw meer bewolking en stijgt de kans op neerslag. Maxima rond 6 graden in het centrum van het land.", + "fr": "Mardi, on prévoit à nouveau davantage de nuages et une augmentation du risque de précipitations. Les maxima varieront autour de 6 degrés dans le centre du pays. " + }, + "dawnRiseSeconds": "31500", + "dawnSetSeconds": "60480", + "tempMin": 2, + "tempMax": 5, + "ww1": 1, + "ww2": null, + "wwevol": null, + "ff1": 3, + "ff2": 2, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 12, + "peakSpeed": null, + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 50, + "precipQuantity": "0" + } + ], + "showWarningTab": false, + "graph": { + "svg": [ + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tx&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tx&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tx&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tx&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tn&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tn&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tn&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=tn&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=rr&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=rr&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=rr&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=rr&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 1.3638709677419354 + } + ] + }, + "hourly": [ + { + "hour": "11", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "18", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 10, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.02, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 10, + "ww": "18", + "precipChance": "70", + "precipQuantity": 0.79, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 9, + "ww": "18", + "precipChance": "70", + "precipQuantity": 0.16, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 9, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 10, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n", + "dateShow": "28/12", + "dateShowLocalized": { + "nl": "Don.", + "fr": "Jeu.", + "en": "Thu.", + "de": "Don." + } + }, + { + "hour": "01", + "temp": 10, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 10, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 9, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 9, + "ww": "0", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 9, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 10, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 35, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 11, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 10, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "18", + "temp": 10, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 10, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 9, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 9, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n", + "dateShow": "29/12", + "dateShowLocalized": { + "nl": "Vri.", + "fr": "Ven.", + "en": "Fri.", + "de": "Fre." + } + }, + { + "hour": "01", + "temp": 9, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.02, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 10, + "ww": "15", + "precipChance": "20", + "precipQuantity": 0.02, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 10, + "ww": "14", + "precipChance": "40", + "precipQuantity": 0.04, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 10, + "ww": "18", + "precipChance": "40", + "precipQuantity": 0.11, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 10, + "ww": "18", + "precipChance": "40", + "precipQuantity": 0.26, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 10, + "ww": "14", + "precipChance": "50", + "precipQuantity": 0.07, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 9, + "ww": "15", + "precipChance": "60", + "precipQuantity": 0.09, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 9, + "ww": "18", + "precipChance": "60", + "precipQuantity": 0.26, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 9, + "ww": "18", + "precipChance": "60", + "precipQuantity": 0.11, + "pressure": 1009, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 9, + "ww": "6", + "precipChance": "70", + "precipQuantity": 0.14, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 10, + "ww": "3", + "precipChance": "50", + "precipQuantity": 0.04, + "pressure": 1010, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + } + ], + "warning": [] + }, + "module": [ + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=pollen&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=pollen&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=pollen&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=21004&e=pollen&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 3.0458333333333334 + } + }, + { + "type": "uv", + "data": { + "levelValue": 0.6, + "level": { + "nl": "Laag", + "fr": "Faible", + "en": "Low", + "de": "Niedrig" + }, + "title": { + "nl": "Uv-index", + "fr": "Indice UV", + "en": "UV Index", + "de": "UV Index" + } + } + }, + { + "type": "observation", + "data": { + "count": 313, + "title": { + "nl": "Waarnemingen vandaag", + "fr": "Observations d'aujourd'hui", + "en": "Today's Observations", + "de": "Beobachtungen heute" + } + } + }, + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=nl&k=893edb0ba7a2f14a0189838896ee8a2e", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=fr&k=893edb0ba7a2f14a0189838896ee8a2e", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=en&k=893edb0ba7a2f14a0189838896ee8a2e", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=de&k=893edb0ba7a2f14a0189838896ee8a2e" + }, + "ratio": 1.6587926509186353 + } + } + ], + "animation": { + "localisationLayer": "https://app.meteo.be/services/appv4/?s=getLocalizationLayer&lat=50.797798&long=4.35811&f=2&k=3040f09e112c427d871465dc145bc9eb", + "localisationLayerRatioX": 0.5821, + "localisationLayerRatioY": 0.4118, + "speed": 0.3, + "type": "10min", + "unit": { + "fr": "mm/10min", + "nl": "mm/10min", + "en": "mm/10min", + "de": "mm/10min" + }, + "country": "BE", + "sequence": [ + { + "time": "2023-12-27T10:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270910&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270920&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270930&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270940&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312270950&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T10:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271000&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271010&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271020&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271030&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271040&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271050&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T11:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271100&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271110&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271120&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271130&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271140&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271150&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T12:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271200&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271210&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271220&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271230&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271240&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271250&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T13:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271300&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271310&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271320&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271330&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271340&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271350&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2023-12-27T14:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202312271400&f=2&k=5a1e5e23504a65226afb1775a7020ef0&d=202312271020", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + } + ], + "threshold": [], + "sequenceHint": { + "nl": "Geen regen voorzien op korte termijn", + "fr": "Pas de pluie prévue prochainement", + "en": "No rain forecasted shortly", + "de": "Kein Regen erwartet in naher Zukunft" + } + }, + "todayObsCount": 313 +} diff --git a/tests/components/irm_kmi/fixtures/high_low_temp.json b/tests/components/irm_kmi/fixtures/high_low_temp.json new file mode 100644 index 00000000000..f1b0e020a4a --- /dev/null +++ b/tests/components/irm_kmi/fixtures/high_low_temp.json @@ -0,0 +1,1635 @@ +{ + "cityName": "Namur", + "country": "BE", + "obs": { + "temp": 4, + "timestamp": "2024-01-21T14:10:00+01:00", + "ww": 15, + "dayNight": "d" + }, + "for": { + "daily": [ + { + "dayName": { + "fr": "Dimanche", + "nl": "Zondag", + "en": "Sunday", + "de": "Sonntag" + }, + "period": "1", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Deze namiddag is het vaak bewolkt en droog, op wat lokaal gedruppel na. Het wordt zachter met maxima van 1 of 2 graden in de Ardennen, 5 graden in het centrum tot 8 graden aan zee. De wind uit zuid tot zuidwest wordt soms vrij krachtig in het binnenland en krachtig aan zee. Verspreid over het land zijn er rukwinden mogelijk tussen 50 en 60 km/h.", + "fr": "Cet après-midi, il fera souvent nuageux mais sec à quelques gouttes près. Le temps sera plus doux avec des maxima de 1 ou 2 degrés en Ardenne, 5 degrés dans le centre jusqu'à 8 degrés à la mer. Le vent de sud à sud-ouest deviendra parfois assez fort dans l'intérieur et fort à la côte avec des rafales de 50 à 60 km/h." + }, + "dawnRiseSeconds": "30780", + "dawnSetSeconds": "62100", + "tempMin": null, + "tempMax": 3, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": 4, + "ffevol": 1, + "dd": 22, + "ddText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + }, + "wind": { + "speed": 29, + "peakSpeed": "50", + "dir": 22, + "dirText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Cette nuit", + "nl": "Vannacht", + "en": "Tonight", + "de": "heute abend" + }, + "period": "2", + "day_night": "0", + "dayNight": "n", + "text": { + "nl": "Vanavond en tijdens het eerste deel van de nacht is het bewolkt en meestal droog. Rond middernacht bereikt een regenzone ons land vanaf de kust en trekt verder oostwaarts. Dit gaat gepaard met meer wind. De wind uit zuidzuidwest spant aan tot krachtig in het binnenland en zeer krachtig aan zee met windstoten tussen 80 en 90 km/h (of zeer plaatselijk iets meer). De minima worden al vroeg tijdens de avond bereikt en liggen tussen 2 en 8 graden. Op het einde van de nacht klimmen de temperaturen naar waarden tussen 4 en 10 graden.", + "fr": "Ce soir et en première partie de nuit, le temps sera encore généralement sec. Autour de minuit, une zone de pluie atteindra le littoral avant de gagner les autres régions. Le vent de sud-sud-ouest se renforcera nettement pour devenir fort dans l'intérieur et très fort à la mer, avec des rafales de 80 à 90 km/h (ou très localement davantage). Les minima oscilleront entre 2 et 8 degrés (atteints en soirée). En fin de nuit, on relèvera 4 à 10 degrés." + }, + "dawnRiseSeconds": "30780", + "dawnSetSeconds": "62100", + "tempMin": 4, + "tempMax": null, + "ww1": 15, + "ww2": null, + "wwevol": null, + "ff1": 5, + "ff2": 6, + "ffevol": 0, + "dd": 22, + "ddText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + }, + "wind": { + "speed": 39, + "peakSpeed": "80", + "dir": 22, + "dirText": { + "fr": "SSO", + "nl": "ZZW", + "en": "SSW", + "de": "SSW" + } + }, + "precipChance": 35, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Lundi", + "nl": "Maandag", + "en": "Monday", + "de": "Montag" + }, + "period": "3", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Maandag bereikt al snel een nieuwe regenzone ons land vanaf het westen. Aan de achterzijde hiervan wordt het grotendeels droog met brede opklaringen. De opklaringen doen zowel Laag- als Midden-België aan, in Hoog-België blijft het vermoedelijk bewolkt en regenachtig. Het wordt nog iets zachter bij maxima tussen 5 en 9 graden ten zuiden van Samber en Maas en 10 of 11 graden elders. In de voormiddag is de wind vaak nog krachtig in het binnenland en zeer krachtig aan zee met rukwinden tussen 80 en 90 km/h. In de namiddag, na de passage van de regenzone, ruimt de wind naar westelijke richtingen en wordt hij matig tot soms vrij krachtig in het binnenland en krachtig aan zee met rukwinden tussen 50 en 60 km/h.\n\nMaandagavond en -nacht is het vrijwel helder met soms enkele hoge wolkensluiers in Laag- en Midden-België. In Hoog-België domineren de lage wolkenvelden en kan er soms nog wat lichte regen of winterse neerslag vallen. De minima liggen tussen 1 en 6 graden. De wind uit westelijke richtingen is matig tot vrij krachtig in het binnenland en krachtig aan zee.", + "fr": "Lundi, une nouvelle zone de pluie atteindra rapidement le pays par l'ouest, suivie de belles éclaircies. En Haute Belgique, le temps restera pluvieux. Il fera encore plus doux avec des maxima de 5 à 9 degrés au sud du sillon Sambre et Meuse et de 10 ou 11 degrés ailleurs. Le vent sera encore assez fort le matin dans l'intérieur et très fort à la mer, avec des pointes de 80 à 90 km/h. L'après-midi, le vent tournera vers l'ouest et deviendra modéré à parfois assez fort, fort à la côte, avec des rafales de 50 à 60 km/h.\n\nLundi soir et la nuit de lundi à mardi, il fera peu nuageux avec parfois quelques voiles d'altitude. En Haute Belgique, les nuages bas domineront encore le ciel avec le risque de faibles pluies ou de précipitations hivernales. Les minima se situeront entre 1 et 6 degrés. Le vent de secteur ouest sera modéré à assez fort dans l'intérieur et fort à la mer." + }, + "dawnRiseSeconds": "30720", + "dawnSetSeconds": "62160", + "tempMin": 1, + "tempMax": 10, + "ww1": 18, + "ww2": 6, + "wwevol": 0, + "ff1": 6, + "ff2": 4, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 39, + "peakSpeed": "80", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 100, + "precipQuantity": "1" + }, + { + "dayName": { + "fr": "Mardi", + "nl": "Dinsdag", + "en": "Tuesday", + "de": "Dienstag" + }, + "period": "5", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Dinsdagochtend is het op veel plaatsen zonnig met hoge wolkenvelden. In de Ardennen begint de dag grijs met lokala mist. Vanaf het westen neemt de bewolking toe en volgt er regen. De maxima worden pas 's avonds laat bereikt; ze liggen dan tussen 7 of 8 graden in de Hoge Venen en 11 graden in Laag-België. De wind waait matig uit zuidwest, toenemend tot vrij krachtig en aan zee tot krachtig. Er zijn rukwinden mogelijk tot zo'n 60 km/h.", + "fr": "Mardi, la matinée sera souvent ensoleillée avec des voiles de nuages élevés. Les nuages bas et la grisaille recouvriront l'Ardenne. En cours de journée, la nébulosité augmentera à partir de l'ouest et des pluies suivront. Les maxima seront atteints en soirée et varieront entre 7 ou 8 degrés dans les Hautes Fagnes et 11 degrés en Basse Belgique. Le vent modéré de sud-ouest deviendra assez fort et même parfois fort le long du littoral avec des rafales autour de 60 km/h." + }, + "dawnRiseSeconds": "30660", + "dawnSetSeconds": "62280", + "tempMin": 3, + "tempMax": 8, + "ww1": 3, + "ww2": 18, + "wwevol": 0, + "ff1": 3, + "ff2": 5, + "ffevol": 0, + "dd": 45, + "ddText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + }, + "wind": { + "speed": 29, + "peakSpeed": "55", + "dir": 45, + "dirText": { + "fr": "SO", + "nl": "ZW", + "en": "SW", + "de": "SW" + } + }, + "precipChance": 50, + "precipQuantity": "1" + }, + { + "dayName": { + "fr": "Mercredi", + "nl": "Woensdag", + "en": "Wednesday", + "de": "Mittwoch" + }, + "period": "7", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Woensdagvoormiddag trekt een regenzone snel van noordwest naar zuidoost. In Vlaanderen wordt het snel droog met brede opklaringen. In de gebieden ten zuiden van Samber en Maas blijft het een groot deel van de dag grijs en regenachtig. De maxima liggen rond 6 of 7 graden in de Hoge Venen, rond 10 graden aan zee en rond 12 graden in het centrum. De wind waait vrij krachtig tot krachtig uit westzuidwest met rukwinden rond 65 km/h. Op het einde van de dag neemt de wind af.", + "fr": "Mercredi, une zone de pluie traversera notre pays du nord-ouest vers le sud-est. En Flandre, de larges éclaircies s'établiront rapidement mais la nébulosité restera abondante dans le sud du pays avec de la pluie. Les maxima oscilleront entre 6 ou 7 degrés en Hautes Fagnes, 10 degrés à la mer et 12 degrés dans le centre. Le vent sera assez fort à fort d'ouest-sud-ouest avec des pointes de 65 km/h. En fin de journée, le vent se calmera." + }, + "dawnRiseSeconds": "30600", + "dawnSetSeconds": "62340", + "tempMin": 12, + "tempMax": 10, + "ww1": 18, + "ww2": 4, + "wwevol": 0, + "ff1": 5, + "ff2": null, + "ffevol": null, + "dd": 90, + "ddText": { + "fr": "O", + "nl": "W", + "en": "W", + "de": "W" + }, + "wind": { + "speed": 29, + "peakSpeed": "70", + "dir": 90, + "dirText": { + "fr": "O", + "nl": "W", + "en": "W", + "de": "W" + } + }, + "precipChance": 100, + "precipQuantity": "2" + }, + { + "dayName": { + "fr": "Jeudi", + "nl": "Donderdag", + "en": "Thursday", + "de": "Donnerstag" + }, + "period": "9", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Donderdag start zonnig met hoge wolkenvelden. Ten zuiden van Samber en Maas begint de dag grijs met lage wolken en/of mist, die hardnekkig kunnen zijn. Geleidelijk neemt de bewolking toe vanaf het westen gevolgd door wat lichte regen. De maxima schommelen rond 9 graden in het centrum.", + "fr": "Jeudi, il fera d'abord ensoleillé avec des nuages élevés. Au sud du sillon Sambre et Meuse, le temps sera encore gris avec des nuages bas et/ou du brouillard tenace. Une faible zone de pluie suivra par l'ouest. Les maxima varieront autour de 9 degrés dans le centre." + }, + "dawnRiseSeconds": "30540", + "dawnSetSeconds": "62460", + "tempMin": 2, + "tempMax": 8, + "ww1": 15, + "ww2": null, + "wwevol": null, + "ff1": 2, + "ff2": 3, + "ffevol": 0, + "dd": 0, + "ddText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + }, + "wind": { + "speed": 12, + "peakSpeed": null, + "dir": 0, + "dirText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + } + }, + "precipChance": 0, + "precipQuantity": "0" + }, + { + "dayName": { + "fr": "Vendredi", + "nl": "Vrijdag", + "en": "Friday", + "de": "Freitag" + }, + "period": "11", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Vrijdag begint zwaarbewolkt met wat regen maar vanaf de kust wordt het vrij snel droog en vrij zonnig. In het zuiden blijft het veelal grijs met nog kans op buien. De maxima liggen in de buurt van 9 of 10 graden in het centrum.", + "fr": "Vendredi, il fera très nuageux avec un peu de pluie. Une belle amélioration se dessinera rapidement depuis la côte, excepté dans le sud du pays. Les maxima oscilleront autour de 9 ou 10 degrés dans le centre." + }, + "dawnRiseSeconds": "30480", + "dawnSetSeconds": "62580", + "tempMin": 6, + "tempMax": 8, + "ww1": 19, + "ww2": null, + "wwevol": null, + "ff1": 4, + "ff2": null, + "ffevol": null, + "dd": 135, + "ddText": { + "fr": "NO", + "nl": "NW", + "en": "NW", + "de": "NW" + }, + "wind": { + "speed": 20, + "peakSpeed": "50", + "dir": 135, + "dirText": { + "fr": "NO", + "nl": "NW", + "en": "NW", + "de": "NW" + } + }, + "precipChance": 100, + "precipQuantity": "2" + }, + { + "dayName": { + "fr": "Samedi", + "nl": "Zaterdag", + "en": "Saturday", + "de": "Samstag" + }, + "period": "13", + "day_night": "1", + "dayNight": "d", + "text": { + "nl": "Zaterdag is het vaak zonnig met middelhoge en hoge wolkenvelden. Later op de dag wordt de middelhoge bewolking wat dikker. De maxima liggen rond 7 graden in het centrum.", + "fr": "Samedi, il fera ensoleillé avec des champs nuageux de moyenne et de haute altitude. En cours de journée, la couverture de nuages moyens s'épaissira. Les maxima se situeront autour de 7 degrés dans le centre." + }, + "dawnRiseSeconds": "30360", + "dawnSetSeconds": "62700", + "tempMin": -2, + "tempMax": 6, + "ww1": 3, + "ww2": null, + "wwevol": null, + "ff1": 2, + "ff2": 3, + "ffevol": 0, + "dd": 0, + "ddText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + }, + "wind": { + "speed": 12, + "peakSpeed": null, + "dir": 0, + "dirText": { + "fr": "S", + "nl": "Z", + "en": "S", + "de": "S" + } + }, + "precipChance": 0, + "precipQuantity": "0" + } + ], + "showWarningTab": true, + "graph": { + "svg": [ + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=en&k=32003c7eac2900f3d73c50f9e27330ab", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tx&l=de&k=32003c7eac2900f3d73c50f9e27330ab" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=en&k=32003c7eac2900f3d73c50f9e27330ab", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=tn&l=de&k=32003c7eac2900f3d73c50f9e27330ab" + }, + "ratio": 1.3638709677419354 + }, + { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=en&k=32003c7eac2900f3d73c50f9e27330ab", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&ins=92094&e=rr&l=de&k=32003c7eac2900f3d73c50f9e27330ab" + }, + "ratio": 1.3638709677419354 + } + ] + }, + "hourly": [ + { + "hour": "14", + "temp": 3, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 3, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 2, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 1, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + }, + { + "hour": "18", + "temp": 1, + "ww": "14", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 2, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1021, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 2, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1020, + "windSpeedKm": 35, + "windPeakSpeedKm": 60, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 3, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1019, + "windSpeedKm": 35, + "windPeakSpeedKm": 65, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 4, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1018, + "windSpeedKm": 40, + "windPeakSpeedKm": 70, + "windDirection": 0, + "windDirectionText": { + "nl": "Z", + "fr": "S", + "en": "S", + "de": "S" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 4, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1017, + "windSpeedKm": 40, + "windPeakSpeedKm": 70, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 5, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.08, + "pressure": 1016, + "windSpeedKm": 40, + "windPeakSpeedKm": 75, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n", + "dateShow": "22/01", + "dateShowLocalized": { + "nl": "Maa.", + "fr": "Lun.", + "en": "Mon.", + "de": "Mon." + } + }, + { + "hour": "01", + "temp": 6, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0.01, + "pressure": 1014, + "windSpeedKm": 45, + "windPeakSpeedKm": 75, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 7, + "ww": "18", + "precipChance": "20", + "precipQuantity": 0.1, + "pressure": 1014, + "windSpeedKm": 45, + "windPeakSpeedKm": 75, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 7, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.03, + "pressure": 1012, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 8, + "ww": "18", + "precipChance": "30", + "precipQuantity": 0.21, + "pressure": 1011, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 8, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.06, + "pressure": 1011, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 9, + "ww": "15", + "precipChance": "30", + "precipQuantity": 0.09, + "pressure": 1011, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 9, + "ww": "18", + "precipChance": "30", + "precipQuantity": 0.11, + "pressure": 1010, + "windSpeedKm": 45, + "windPeakSpeedKm": 80, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 9, + "ww": "14", + "precipChance": "30", + "precipQuantity": 0.04, + "pressure": 1011, + "windSpeedKm": 40, + "windPeakSpeedKm": 75, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0.02, + "pressure": 1011, + "windSpeedKm": 35, + "windPeakSpeedKm": 70, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 9, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0.04, + "pressure": 1011, + "windSpeedKm": 30, + "windPeakSpeedKm": 65, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 9, + "ww": "3", + "precipChance": "30", + "precipQuantity": 0.06, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 10, + "ww": "6", + "precipChance": "40", + "precipQuantity": 0.7100000000000001, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 9, + "ww": "18", + "precipChance": "40", + "precipQuantity": 0.22, + "pressure": 1012, + "windSpeedKm": 30, + "windPeakSpeedKm": 60, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 8, + "ww": "15", + "precipChance": "40", + "precipQuantity": 0.03, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": 60, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "15", + "temp": 7, + "ww": "3", + "precipChance": "20", + "precipQuantity": 0, + "pressure": 1012, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "16", + "temp": 7, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1013, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "17", + "temp": 6, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1014, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "d" + }, + { + "hour": "18", + "temp": 6, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1015, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "19", + "temp": 5, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1016, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "20", + "temp": 5, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1017, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "21", + "temp": 5, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1018, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "22", + "temp": 5, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0.02, + "pressure": 1019, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "23", + "temp": 5, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1019, + "windSpeedKm": 30, + "windPeakSpeedKm": 55, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "00", + "temp": 5, + "ww": "4", + "precipChance": "20", + "precipQuantity": 0.1, + "pressure": 1020, + "windSpeedKm": 30, + "windPeakSpeedKm": 50, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n", + "dateShow": "23/01", + "dateShowLocalized": { + "nl": "Din.", + "fr": "Mar.", + "en": "Tue.", + "de": "Die." + } + }, + { + "hour": "01", + "temp": 5, + "ww": "1", + "precipChance": "10", + "precipQuantity": 0.01, + "pressure": 1022, + "windSpeedKm": 25, + "windPeakSpeedKm": 55, + "windDirection": 90, + "windDirectionText": { + "nl": "W", + "fr": "O", + "en": "W", + "de": "W" + }, + "dayNight": "n" + }, + { + "hour": "02", + "temp": 4, + "ww": "1", + "precipChance": "10", + "precipQuantity": 0, + "pressure": 1022, + "windSpeedKm": 25, + "windPeakSpeedKm": 50, + "windDirection": 90, + "windDirectionText": { + "nl": "W", + "fr": "O", + "en": "W", + "de": "W" + }, + "dayNight": "n" + }, + { + "hour": "03", + "temp": 4, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1023, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 90, + "windDirectionText": { + "nl": "W", + "fr": "O", + "en": "W", + "de": "W" + }, + "dayNight": "n" + }, + { + "hour": "04", + "temp": 4, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1024, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "05", + "temp": 3, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1025, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "06", + "temp": 3, + "ww": "0", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1026, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 68, + "windDirectionText": { + "nl": "WZW", + "fr": "OSO", + "en": "WSW", + "de": "WSW" + }, + "dayNight": "n" + }, + { + "hour": "07", + "temp": 3, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1026, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "08", + "temp": 3, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1027, + "windSpeedKm": 15, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "n" + }, + { + "hour": "09", + "temp": 3, + "ww": "1", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1028, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "10", + "temp": 4, + "ww": "3", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1028, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "11", + "temp": 6, + "ww": "15", + "precipChance": "0", + "precipQuantity": 0, + "pressure": 1028, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "12", + "temp": 6, + "ww": "15", + "precipChance": "20", + "precipQuantity": 0, + "pressure": 1029, + "windSpeedKm": 20, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "13", + "temp": 6, + "ww": "15", + "precipChance": "40", + "precipQuantity": 0.09, + "pressure": 1028, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 45, + "windDirectionText": { + "nl": "ZW", + "fr": "SO", + "en": "SW", + "de": "SW" + }, + "dayNight": "d" + }, + { + "hour": "14", + "temp": 7, + "ww": "18", + "precipChance": "60", + "precipQuantity": 0.2, + "pressure": 1027, + "windSpeedKm": 25, + "windPeakSpeedKm": null, + "windDirection": 23, + "windDirectionText": { + "nl": "ZZW", + "fr": "SSO", + "en": "SSW", + "de": "SSW" + }, + "dayNight": "d" + } + ], + "warning": [ + { + "icon_country": "BE", + "warningType": { + "id": "0", + "name": { + "fr": "Vent", + "nl": "Wind", + "en": "Wind", + "de": "Wind" + } + }, + "warningLevel": "1", + "text": { + "fr": "Ce soir et cette nuit, le vent se renforcera progressivement pour devenir fort dans l'intérieur et très fort à la mer. Des rafales de 80 à 90 km/h pourront se produire (très localement un peu plus). Lundi après-midi, les rafales se limiteront à des valeurs comprises entre 50 et 60 km/h.", + "nl": "Vanavond en vannacht spant de wind aan en wordt hij krachtig in het binnenland en zeer krachtig aan zee met rukwinden tussen 80 en 90 km/h (of zeer lokaal iets meer). Maandagnamiddag neemt hij af in kracht en zijn nog rukwinden mogelijk tussen 50 en 60 km/h.", + "en": "There is a strong wind expected where local troubles or damage is possible and traffic congestion may arise. Be careful.", + "de": "Es wird viel Wind erwartet, wobei lokale Beeinträchtigungen und Verkehrshindernisse entstehen können. Seien Sie vorsichtig." + }, + "fromTimestamp": "2024-01-21T23:00:00+01:00", + "toTimestamp": "2024-01-22T13:00:00+01:00" + } + ] + }, + "module": [ + { + "type": "uv", + "data": { + "levelValue": 0.7, + "level": { + "nl": "Laag", + "fr": "Faible", + "en": "Low", + "de": "Niedrig" + }, + "title": { + "nl": "Uv-index", + "fr": "Indice UV", + "en": "UV Index", + "de": "UV Index" + } + } + }, + { + "type": "observation", + "data": { + "count": 773, + "title": { + "nl": "Waarnemingen vandaag", + "fr": "Observations d'aujourd'hui", + "en": "Today's Observations", + "de": "Beobachtungen heute" + } + } + }, + { + "type": "svg", + "data": { + "url": { + "nl": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", + "fr": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", + "en": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=en&k=32003c7eac2900f3d73c50f9e27330ab", + "de": "https://app.meteo.be/services/appv4/?s=getSvg&e=efem&l=de&k=32003c7eac2900f3d73c50f9e27330ab" + }, + "ratio": 1.6587926509186353 + } + } + ], + "animation": { + "localisationLayer": "https://app.meteo.be/services/appv4/?s=getLocalizationLayerBE&ins=92094&f=2&k=83d708d73ec391c032e6d5fb70f7e71a", + "localisationLayerRatioX": 0.6667, + "localisationLayerRatioY": 0.523, + "speed": 0.3, + "type": "10min", + "unit": { + "fr": "mm/10min", + "nl": "mm/10min", + "en": "mm/10min", + "de": "mm/10min" + }, + "country": "BE", + "sequence": [ + { + "time": "2024-01-21T13:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211210&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211220&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211230&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211240&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211250&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T13:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211300&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211310&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211320&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211330&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211340&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211350&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T14:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211400&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211410&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211420&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211430&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211440&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211450&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T15:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211500&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211510&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211520&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211530&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211540&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211550&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T16:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211600&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:00:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211610&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:10:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211620&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:20:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211630&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:30:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211640&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:40:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211650&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + }, + { + "time": "2024-01-21T17:50:00+01:00", + "uri": "https://app.meteo.be/services/appv4/?s=getIncaImage&i=202401211700&f=2&k=c271685a8fd5ae335b2aa85654212f23&d=202401211310", + "value": 0, + "position": 0, + "positionLower": 0, + "positionHigher": 0 + } + ], + "threshold": [], + "sequenceHint": { + "nl": "Geen regen voorzien op korte termijn", + "fr": "Pas de pluie prévue prochainement", + "en": "No rain forecasted shortly", + "de": "Kein Regen erwartet in naher Zukunft" + } + }, + "todayObsCount": 773 +} diff --git a/tests/components/irm_kmi/snapshots/test_weather.ambr b/tests/components/irm_kmi/snapshots/test_weather.ambr new file mode 100644 index 00000000000..a8a0c92b539 --- /dev/null +++ b/tests/components/irm_kmi/snapshots/test_weather.ambr @@ -0,0 +1,694 @@ +# serializer version: 1 +# name: test_forecast_service[daily] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-28', + 'is_daytime': True, + 'precipitation': 0.1, + 'precipitation_probability': None, + 'sunrise': '2023-12-28T08:47:43+01:00', + 'sunset': '2023-12-28T16:34:06+01:00', + 'temperature': 11.0, + 'templow': 9.0, + 'text': ''' + Waarschuwingen + Vanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel). + + Vanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur. + Vanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur. + Vanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur. + + Komende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur. + + Morgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur. + Morgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur. + Morgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8. + (Bron: KNMI, 2023-12-28T06:56:00+01:00) + + ''', + 'wind_bearing': 225.0, + 'wind_gust_speed': 33.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-29', + 'is_daytime': True, + 'precipitation': 3.8, + 'precipitation_probability': None, + 'sunrise': '2023-12-29T08:47:48+01:00', + 'sunset': '2023-12-29T16:35:00+01:00', + 'temperature': 10.0, + 'text': '', + 'wind_bearing': 248.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-30', + 'is_daytime': True, + 'precipitation': 1.7, + 'precipitation_probability': None, + 'sunrise': '2023-12-30T08:47:49+01:00', + 'sunset': '2023-12-30T16:35:57+01:00', + 'temperature': 10.0, + 'templow': 5.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2023-12-31', + 'is_daytime': True, + 'precipitation': 4.2, + 'precipitation_probability': None, + 'sunrise': '2023-12-31T08:47:47+01:00', + 'sunset': '2023-12-31T16:36:56+01:00', + 'temperature': 9.0, + 'templow': 7.0, + 'text': '', + 'wind_bearing': 203.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 30.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-01', + 'is_daytime': True, + 'precipitation': 2.2, + 'precipitation_probability': None, + 'sunrise': '2024-01-01T08:47:42+01:00', + 'sunset': '2024-01-01T16:37:59+01:00', + 'temperature': 7.0, + 'templow': 5.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-02', + 'is_daytime': True, + 'precipitation': 1.4, + 'precipitation_probability': None, + 'sunrise': '2024-01-02T08:47:32+01:00', + 'sunset': '2024-01-02T16:39:04+01:00', + 'temperature': 6.0, + 'templow': 3.0, + 'text': '', + 'wind_bearing': 225.0, + 'wind_gust_speed': 16.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'pouring', + 'condition_2': None, + 'condition_evol': , + 'datetime': '2024-01-03', + 'is_daytime': True, + 'precipitation': 1.0, + 'precipitation_probability': None, + 'sunrise': '2024-01-03T08:47:20+01:00', + 'sunset': '2024-01-03T16:40:12+01:00', + 'temperature': 6.0, + 'templow': 3.0, + 'text': '', + 'wind_bearing': 203.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 13.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[hourly] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T15:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 33.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T16:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T17:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T18:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T19:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T20:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-22T21:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-22T22:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.7, + 'precipitation_probability': 70, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-22T23:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.1, + 'precipitation_probability': 10, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 37.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T00:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 20, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T01:00:00+02:00', + 'is_daytime': False, + 'precipitation': 1.9, + 'precipitation_probability': 80, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 225.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T02:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.6, + 'precipitation_probability': 70, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 38.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T03:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T04:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T05:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 35.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T06:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T07:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T08:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T09:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T10:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T11:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 32.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T12:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 34.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T13:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 33.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T14:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 31.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-23T15:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 10.0, + 'wind_bearing': 248.0, + 'wind_speed': 28.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-23T16:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 9.0, + 'wind_bearing': 248.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-23T17:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 225.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T18:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 225.0, + 'wind_speed': 18.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T19:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1005.0, + 'temperature': 8.0, + 'wind_bearing': 203.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T20:00:00+02:00', + 'is_daytime': False, + 'precipitation': 5.7, + 'precipitation_probability': 100, + 'pressure': 1005.0, + 'temperature': 8.0, + 'wind_bearing': 248.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2025-09-23T21:00:00+02:00', + 'is_daytime': False, + 'precipitation': 3.8, + 'precipitation_probability': 100, + 'pressure': 1006.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-23T22:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1006.0, + 'temperature': 8.0, + 'wind_bearing': 248.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2025-09-23T23:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 7.0, + 'wind_bearing': 248.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T00:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 8.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T01:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1007.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 26.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T02:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T03:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1008.0, + 'temperature': 7.0, + 'wind_bearing': 270.0, + 'wind_speed': 24.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T04:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 7.0, + 'wind_bearing': 248.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2025-09-24T05:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 23.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T06:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1009.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T07:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1010.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T08:00:00+02:00', + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1011.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2025-09-24T09:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1011.0, + 'temperature': 6.0, + 'wind_bearing': 248.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2025-09-24T10:00:00+02:00', + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1012.0, + 'temperature': 5.0, + 'wind_bearing': 225.0, + 'wind_speed': 12.0, + }), + ]), + }), + }) +# --- +# name: test_weather_nl[weather.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.home', + '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': 'irm_kmi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'city country', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_nl[weather.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data from the Royal Meteorological Institute of Belgium meteo.be', + 'friendly_name': 'Home', + 'precipitation_unit': , + 'pressure': 1008.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 11.0, + 'temperature_unit': , + 'uv_index': 1, + 'visibility_unit': , + 'wind_bearing': 225.0, + 'wind_speed': 40.0, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- diff --git a/tests/components/irm_kmi/test_config_flow.py b/tests/components/irm_kmi/test_config_flow.py new file mode 100644 index 00000000000..46eba74a7a5 --- /dev/null +++ b/tests/components/irm_kmi/test_config_flow.py @@ -0,0 +1,154 @@ +"""Tests for the IRM KMI config flow.""" + +from unittest.mock import MagicMock + +from homeassistant.components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_LOCATION, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_in_benelux: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + 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_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Brussels" + assert result.get("data") == { + CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}, + CONF_UNIQUE_ID: "brussels be", + } + + +async def test_user_flow_home( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_in_benelux: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Brussels" + + +async def test_config_flow_location_out_benelux( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_out_benelux_then_in_belgium: MagicMock, +) -> None: + """Test configuration flow with a zone outside of Benelux.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 0.123, ATTR_LONGITUDE: 0.456}}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert CONF_LOCATION in result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_config_flow_with_api_error( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_api_error: MagicMock, +) -> None: + """Test when API returns an error during the configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}}, + ) + + assert result.get("type") is FlowResultType.ABORT + + +async def test_setup_twice_same_location( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_in_benelux: MagicMock, +) -> None: + """Test when the user tries to set up the weather twice for the same location.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.5, ATTR_LONGITUDE: 4.6}}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + + # Set up a second time + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.5, ATTR_LONGITUDE: 4.6}}, + ) + assert result.get("type") is FlowResultType.ABORT + + +async def test_option_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test when the user changes options with the option flow.""" + mock_config_entry.add_to_hass(hass) + + assert not mock_config_entry.options + + result = await hass.config_entries.options.async_init( + mock_config_entry.entry_id, data=None + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_LANGUAGE_OVERRIDE: "none"} diff --git a/tests/components/irm_kmi/test_init.py b/tests/components/irm_kmi/test_init.py new file mode 100644 index 00000000000..4fa310ab81a --- /dev/null +++ b/tests/components/irm_kmi/test_init.py @@ -0,0 +1,43 @@ +"""Tests for the IRM KMI integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.irm_kmi.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_irm_kmi_api: AsyncMock, +) -> None: + """Test the IRM KMI configuration entry loading/unloading.""" + 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 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_exception_irm_kmi_api: AsyncMock, +) -> None: + """Test the IRM KMI configuration entry not ready.""" + 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_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/irm_kmi/test_weather.py b/tests/components/irm_kmi/test_weather.py new file mode 100644 index 00000000000..c563f7b5314 --- /dev/null +++ b/tests/components/irm_kmi/test_weather.py @@ -0,0 +1,100 @@ +"""Test for the weather entity of the IRM KMI integration.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.weather import ( + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00") +async def test_weather_nl( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_irm_kmi_api_nl: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test weather with forecast from the Netherland.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "forecast_type", + ["daily", "hourly"], +) +@pytest.mark.freeze_time("2025-09-22T15:30:00+01:00") +async def test_forecast_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_irm_kmi_api_nl: AsyncMock, + mock_config_entry: MockConfigEntry, + forecast_type: str, +) -> None: + """Test multiple forecast.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_ENTITY_ID: "weather.home", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +@pytest.mark.freeze_time("2024-01-21T14:15:00+01:00") +@pytest.mark.parametrize( + "forecast_type", + ["daily", "hourly"], +) +async def test_weather_higher_temp_at_night( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_irm_kmi_api_high_low_temp: AsyncMock, + forecast_type: str, +) -> None: + """Test that the templow is always lower than temperature, even when API returns the opposite.""" + # Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8 + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_ENTITY_ID: "weather.home", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + for forecast in response["weather.home"]["forecast"]: + assert ( + forecast.get("native_temperature") is None + or forecast.get("native_templow") is None + or forecast["native_temperature"] >= forecast["native_templow"] + ) diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 859cdefd9c2..381002b1f8b 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -3,15 +3,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 40, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -26,22 +17,33 @@ 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 40, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 40, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", + }), }), }), }), @@ -57,15 +59,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', - }), 'diaspora': True, 'havdalah_offset': 0, 'language': 'en', @@ -80,22 +73,33 @@ 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': True, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", + }), }), }), }), @@ -111,15 +115,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -134,22 +129,33 @@ 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", + }), }), }), }), diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr index fe22f19fb7a..c7161a3d284 100644 --- a/tests/components/kitchen_sink/snapshots/test_init.ambr +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -7,7 +7,7 @@ 'metadata_unit': 'm³', 'state_unit': 'W', 'statistic_id': 'sensor.statistics_issues_issue_1', - 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + 'supported_unit': 'CCF, L, MCF, fl. oz., ft³, gal, mL, m³', }), 'type': 'units_changed', }), @@ -35,7 +35,7 @@ 'metadata_unit': 'm³', 'state_unit': 'W', 'statistic_id': 'sensor.statistics_issues_issue_3', - 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + 'supported_unit': 'CCF, L, MCF, fl. oz., ft³, gal, mL, m³', }), 'type': 'units_changed', }), diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index 2b6e5887f9e..010149df07d 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 1, + "minor_version": 2, "key": "knx/config_store.json", "data": { "entities": { @@ -17,7 +17,6 @@ "state": "3/2/21", "passive": [] }, - "respond_to_read": false, "sync_state": true } } diff --git a/tests/components/knx/fixtures/config_store_binarysensor_v2_1.json b/tests/components/knx/fixtures/config_store_binarysensor_v2_1.json new file mode 100644 index 00000000000..2b6e5887f9e --- /dev/null +++ b/tests/components/knx/fixtures/config_store_binarysensor_v2_1.json @@ -0,0 +1,27 @@ +{ + "version": 2, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": {}, + "binary_sensor": { + "knx_es_01JJP1XDQRXB0W6YYGXW6Y1X10": { + "entity": { + "name": "test", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_sensor": { + "state": "3/2/21", + "passive": [] + }, + "respond_to_read": false, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json index 61ec1044746..e0e1089ed2d 100644 --- a/tests/components/knx/fixtures/config_store_light.json +++ b/tests/components/knx/fixtures/config_store_light.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 1, + "minor_version": 2, "key": "knx/config_store.json", "data": { "entities": { diff --git a/tests/components/knx/snapshots/test_diagnostic.ambr b/tests/components/knx/snapshots/test_diagnostic.ambr index 4323dd113cd..674baa20e1e 100644 --- a/tests/components/knx/snapshots/test_diagnostic.ambr +++ b/tests/components/knx/snapshots/test_diagnostic.ambr @@ -9,7 +9,10 @@ 'rate_limit': 0, 'state_updater': True, }), - 'configuration_error': "extra keys not allowed @ data['knx']['wrong_key']", + 'config_store': dict({ + 'entities': dict({ + }), + }), 'configuration_yaml': dict({ 'wrong_key': dict({ }), @@ -19,6 +22,7 @@ 'current_address': '0.0.0', 'version': '0.0.0', }), + 'yaml_configuration_error': "extra keys not allowed @ data['knx']['wrong_key']", }) # --- # name: test_diagnostic_redact[hass_config0] @@ -35,13 +39,17 @@ 'state_updater': True, 'user_password': '**REDACTED**', }), - 'configuration_error': None, + 'config_store': dict({ + 'entities': dict({ + }), + }), 'configuration_yaml': None, 'project_info': None, 'xknx': dict({ 'current_address': '0.0.0', 'version': '0.0.0', }), + 'yaml_configuration_error': None, }) # --- # name: test_diagnostics[hass_config0] @@ -54,13 +62,17 @@ 'rate_limit': 0, 'state_updater': True, }), - 'configuration_error': None, + 'config_store': dict({ + 'entities': dict({ + }), + }), 'configuration_yaml': None, 'project_info': None, 'xknx': dict({ 'current_address': '0.0.0', 'version': '0.0.0', }), + 'yaml_configuration_error': None, }) # --- # name: test_diagnostics_project[hass_config0] @@ -73,7 +85,50 @@ 'rate_limit': 0, 'state_updater': True, }), - 'configuration_error': None, + 'config_store': dict({ + 'entities': dict({ + 'light': dict({ + 'knx_es_01J85ZKTFHSZNG4X9DYBE592TF': dict({ + 'entity': dict({ + 'device_info': None, + 'entity_category': 'config', + 'name': 'test', + }), + 'knx': dict({ + 'color_temp_max': 6000, + 'color_temp_min': 2700, + 'ga_switch': dict({ + 'passive': list([ + ]), + 'state': '1/0/21', + 'write': '1/1/21', + }), + 'sync_state': True, + }), + }), + }), + 'switch': dict({ + 'knx_es_9d97829f47f1a2a3176a7c5b4216070c': dict({ + 'entity': dict({ + 'device_info': 'knx_vdev_4c80a564f5fe5da701ed293966d6384d', + 'entity_category': None, + 'name': 'test', + }), + 'knx': dict({ + 'ga_switch': dict({ + 'passive': list([ + ]), + 'state': '1/0/45', + 'write': '1/1/45', + }), + 'invert': False, + 'respond_to_read': False, + 'sync_state': True, + }), + }), + }), + }), + }), 'configuration_yaml': None, 'project_info': dict({ 'created_by': 'ETS5', @@ -91,5 +146,6 @@ 'current_address': '0.0.0', 'version': '0.0.0', }), + 'yaml_configuration_error': None, }) # --- diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index b99196c8769..388c68e0d3f 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -111,6 +111,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -140,6 +146,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -153,6 +165,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -172,6 +190,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': dict({ 'required': False, }), @@ -187,6 +211,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': False, }), 'required': False, @@ -216,6 +246,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': dict({ 'required': False, }), @@ -242,8 +278,7 @@ dict({ 'default': 25, 'name': 'travelling_time_up', - 'optional': True, - 'required': False, + 'required': True, 'selector': dict({ 'number': dict({ 'max': 1000.0, @@ -258,8 +293,7 @@ dict({ 'default': 25, 'name': 'travelling_time_down', - 'optional': True, - 'required': False, + 'required': True, 'selector': dict({ 'number': dict({ 'max': 1000.0, @@ -574,26 +608,6 @@ 'required': False, 'type': 'knx_section_flat', }), - dict({ - 'name': 'ga_blue_brightness', - 'options': dict({ - 'passive': True, - 'state': dict({ - 'required': False, - }), - 'validDPTs': list([ - dict({ - 'main': 5, - 'sub': 1, - }), - ]), - 'write': dict({ - 'required': True, - }), - }), - 'required': True, - 'type': 'knx_group_address', - }), dict({ 'name': 'ga_blue_switch', 'optional': True, @@ -616,14 +630,7 @@ 'type': 'knx_group_address', }), dict({ - 'collapsible': False, - 'name': 'section_white', - 'required': False, - 'type': 'knx_section_flat', - }), - dict({ - 'name': 'ga_white_brightness', - 'optional': True, + 'name': 'ga_blue_brightness', 'options': dict({ 'passive': True, 'state': dict({ @@ -639,9 +646,15 @@ 'required': True, }), }), - 'required': False, + 'required': True, 'type': 'knx_group_address', }), + dict({ + 'collapsible': False, + 'name': 'section_white', + 'required': False, + 'type': 'knx_section_flat', + }), dict({ 'name': 'ga_white_switch', 'optional': True, @@ -663,6 +676,27 @@ 'required': False, 'type': 'knx_group_address', }), + dict({ + 'name': 'ga_white_brightness', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), ]), 'translation_key': 'individual_addresses', 'type': 'knx_group_select_option', @@ -746,6 +780,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': True, }), diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 3e902f8f402..8f11888d1f2 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -88,7 +88,7 @@ async def test_create_entity_error( assert res["success"], res assert not res["result"]["success"] assert res["result"]["errors"][0]["path"] == ["platform"] - assert res["result"]["error_base"].startswith("expected Platform or one of") + assert res["result"]["error_base"].startswith("expected EntityPlatforms or one of") # create entity with unsupported platform await client.send_json_auto_id( @@ -458,3 +458,19 @@ async def test_migration_1_to_2( hass, "config_store_light.json", "knx" ) assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data + + +async def test_migration_2_1_to_2_2( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +) -> None: + """Test migration from schema 2.1 to schema 2.2.""" + await knx.setup_integration( + config_store_fixture="config_store_binarysensor_v2_1.json", + state_updater=False, + ) + new_data = await async_load_json_object_fixture( + hass, "config_store_binarysensor.json", "knx" + ) + assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index 1b63e4a3f9a..f3410644540 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -120,9 +120,13 @@ async def test_diagnostics_project( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration() + await knx.setup_integration( + config_store_fixture="config_store_light_switch.json", + state_updater=False, + ) knx.xknx.version = "0.0.0" # snapshot will contain project specific fields in `project_info` + # and UI configuration in `config_store` assert ( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) == snapshot diff --git a/tests/components/knx/test_scene.py b/tests/components/knx/test_scene.py index 8598ef0a627..7dc850b4843 100644 --- a/tests/components/knx/test_scene.py +++ b/tests/components/knx/test_scene.py @@ -8,6 +8,8 @@ from homeassistant.helpers import entity_registry as er from .conftest import KNXTestKit +from tests.common import async_capture_events + async def test_activate_knx_scene( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry @@ -30,9 +32,27 @@ async def test_activate_knx_scene( assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == "1/1/1_24" + events = async_capture_events(hass, "state_changed") + + # activate scene from HA await hass.services.async_call( "scene", "turn_on", {"entity_id": "scene.test"}, blocking=True ) - - # assert scene was called on bus await knx.assert_write("1/1/1", (0x17,)) + assert len(events) == 1 + # consecutive call from HA + await hass.services.async_call( + "scene", "turn_on", {"entity_id": "scene.test"}, blocking=True + ) + await knx.assert_write("1/1/1", (0x17,)) + assert len(events) == 2 + + # scene activation from bus + await knx.receive_write("1/1/1", (0x17,)) + assert len(events) == 3 + # same scene number consecutive call + await knx.receive_write("1/1/1", (0x17,)) + assert len(events) == 4 + # different scene number - should not be recorded + await knx.receive_write("1/1/1", (0x00,)) + assert len(events) == 4 diff --git a/tests/components/kodi/test_config_flow.py b/tests/components/kodi/test_config_flow.py index ad99067ac7a..d8968ef1449 100644 --- a/tests/components/kodi/test_config_flow.py +++ b/tests/components/kodi/test_config_flow.py @@ -18,7 +18,6 @@ from .util import ( TEST_DISCOVERY, TEST_DISCOVERY_WO_UUID, TEST_HOST, - TEST_IMPORT, TEST_WS_PORT, UUID, MockConnection, @@ -666,99 +665,3 @@ async def test_discovery_without_unique_id(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_uuid" - - -async def test_form_import(hass: HomeAssistant) -> None: - """Test we get the form with import source.""" - with ( - patch( - "homeassistant.components.kodi.config_flow.Kodi.ping", - return_value=True, - ), - patch( - "homeassistant.components.kodi.config_flow.get_kodi_connection", - return_value=MockConnection(), - ), - patch( - "homeassistant.components.kodi.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_IMPORT, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_IMPORT["name"] - assert result["data"] == TEST_IMPORT - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_import_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth on import.""" - with ( - patch( - "homeassistant.components.kodi.config_flow.Kodi.ping", - side_effect=InvalidAuthError, - ), - patch( - "homeassistant.components.kodi.config_flow.get_kodi_connection", - return_value=MockConnection(), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_IMPORT, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "invalid_auth" - - -async def test_form_import_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect on import.""" - with ( - patch( - "homeassistant.components.kodi.config_flow.Kodi.ping", - side_effect=CannotConnectError, - ), - patch( - "homeassistant.components.kodi.config_flow.get_kodi_connection", - return_value=MockConnection(), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_IMPORT, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_form_import_exception(hass: HomeAssistant) -> None: - """Test we handle unknown exception on import.""" - with ( - patch( - "homeassistant.components.kodi.config_flow.Kodi.ping", - side_effect=Exception, - ), - patch( - "homeassistant.components.kodi.config_flow.get_kodi_connection", - return_value=MockConnection(), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_IMPORT, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index 80493aa83c9..55335f720c3 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -54,3 +54,6 @@ def get_bluetooth_service_info(model: ModelName, serial: str) -> BluetoothServic service_uuids=[], source="local", ) + + +MOCK_INSTALLATION_KEY = '{"secret": "K9ZW2vlMSb3QXmhySx4pxAbTHujWj3VZ01Jn3D/sO98=", "private_key": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg8iotE8El786F6kHuEL8GyYhjDB7oo06vNhQwtewF37yhRANCAAQCLb9lHskiavvfkI4H2B+WsdkusfgBBFuFNRrGV8bqPMra1TK5myb/ecdZfHJBBJrcbdt90QMDmXQm5L3muXXe", "installation_id": "4e966f3f-2abc-49c4-a362-3cd3346f1a87"}' diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index ad1378a6dc1..7907a1d6a7e 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -12,13 +12,14 @@ from pylamarzocco.models import ( ThingSettings, ThingStatistics, ) +from pylamarzocco.util import InstallationKey import pytest -from homeassistant.components.lamarzocco.const import DOMAIN +from homeassistant.components.lamarzocco.const import CONF_INSTALLATION_KEY, DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant -from . import SERIAL_DICT, USER_INPUT, async_init_integration +from . import MOCK_INSTALLATION_KEY, SERIAL_DICT, USER_INPUT, async_init_integration from tests.common import MockConfigEntry, load_json_object_fixture @@ -31,11 +32,12 @@ def mock_config_entry( return MockConfigEntry( title="My LaMarzocco", domain=DOMAIN, - version=3, + version=4, data=USER_INPUT | { CONF_ADDRESS: "000000000000", CONF_TOKEN: "token", + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, }, unique_id=mock_lamarzocco.serial_number, ) @@ -51,6 +53,22 @@ async def init_integration( return mock_config_entry +@pytest.fixture(autouse=True) +def mock_generate_installation_key() -> Generator[MagicMock]: + """Return a mocked generate_installation_key.""" + with ( + patch( + "homeassistant.components.lamarzocco.generate_installation_key", + return_value=InstallationKey.from_json(MOCK_INSTALLATION_KEY), + ) as mock_generate, + patch( + "homeassistant.components.lamarzocco.config_flow.generate_installation_key", + new=mock_generate, + ), + ): + yield mock_generate + + @pytest.fixture def device_fixture() -> ModelName: """Return the device fixture for a specific device.""" diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 3dd1ff9b665..1ded7231287 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -45,7 +45,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2025-05-07T18:04:20+00:00', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-entry] diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index e50707f71af..5d0a514b793 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -9,7 +9,11 @@ from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE -from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN +from homeassistant.components.lamarzocco.const import ( + CONF_INSTALLATION_KEY, + CONF_USE_BLUETOOTH, + DOMAIN, +) from homeassistant.config_entries import ( SOURCE_BLUETOOTH, SOURCE_DHCP, @@ -23,7 +27,12 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import USER_INPUT, async_init_integration, get_bluetooth_service_info +from . import ( + MOCK_INSTALLATION_KEY, + USER_INPUT, + async_init_integration, + get_bluetooth_service_info, +) from tests.common import MockConfigEntry @@ -68,6 +77,7 @@ async def __do_sucessful_machine_selection_step( assert result["data"] == { **USER_INPUT, CONF_TOKEN: None, + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } assert result["result"].unique_id == "GS012345" @@ -344,6 +354,7 @@ async def test_bluetooth_discovery( **USER_INPUT, CONF_MAC: "aa:bb:cc:dd:ee:ff", CONF_TOKEN: "dummyToken", + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } @@ -407,6 +418,7 @@ async def test_bluetooth_discovery_errors( **USER_INPUT, CONF_MAC: "aa:bb:cc:dd:ee:ff", CONF_TOKEN: None, + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } @@ -438,6 +450,7 @@ async def test_dhcp_discovery( **USER_INPUT, CONF_ADDRESS: "aabbccddeeff", CONF_TOKEN: None, + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 1e56e540e2a..e6bf4a0af62 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -8,15 +8,11 @@ from pylamarzocco.models import WebSocketDetails import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE -from homeassistant.components.lamarzocco.const import DOMAIN +from homeassistant.components.lamarzocco.const import CONF_INSTALLATION_KEY, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_ADDRESS, - CONF_HOST, CONF_MAC, - CONF_MODEL, - CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, ) @@ -27,7 +23,12 @@ from homeassistant.helpers import ( issue_registry as ir, ) -from . import USER_INPUT, async_init_integration, get_bluetooth_service_info +from . import ( + MOCK_INSTALLATION_KEY, + USER_INPUT, + async_init_integration, + get_bluetooth_service_info, +) from tests.common import MockConfigEntry @@ -129,66 +130,65 @@ async def test_v1_migration_fails( assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR -async def test_v2_migration( +async def test_v4_migration( hass: HomeAssistant, mock_lamarzocco: MagicMock, ) -> None: - """Test v2 -> v3 Migration.""" + """Test v3 -> v4 Migration.""" - entry_v2 = MockConfigEntry( + entry_v3 = MockConfigEntry( domain=DOMAIN, - version=2, + version=3, unique_id=mock_lamarzocco.serial_number, data={ **USER_INPUT, - CONF_HOST: "192.168.1.24", - CONF_NAME: "La Marzocco", - CONF_MODEL: ModelName.GS3_MP.value, - CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_ADDRESS: "000000000000", + CONF_TOKEN: "token", }, ) - entry_v2.add_to_hass(hass) + entry_v3.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry_v2.entry_id) - assert entry_v2.state is ConfigEntryState.LOADED - assert entry_v2.version == 3 - assert dict(entry_v2.data) == { + assert await hass.config_entries.async_setup(entry_v3.entry_id) + assert entry_v3.state is ConfigEntryState.LOADED + assert entry_v3.version == 4 + assert dict(entry_v3.data) == { **USER_INPUT, - CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_TOKEN: None, + CONF_ADDRESS: "000000000000", + CONF_TOKEN: "token", + CONF_INSTALLATION_KEY: MOCK_INSTALLATION_KEY, } async def test_migration_errors( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_cloud_client: MagicMock, mock_lamarzocco: MagicMock, ) -> None: """Test errors during migration.""" - mock_cloud_client.list_things.side_effect = RequestNotSuccessful("Error") + mock_cloud_client.async_register_client.side_effect = RequestNotSuccessful("Error") - entry_v2 = MockConfigEntry( + entry_v3 = MockConfigEntry( domain=DOMAIN, - version=2, + version=3, unique_id=mock_lamarzocco.serial_number, data={ **USER_INPUT, - CONF_MACHINE: mock_lamarzocco.serial_number, + CONF_ADDRESS: "000000000000", + CONF_TOKEN: "token", }, ) - entry_v2.add_to_hass(hass) + entry_v3.add_to_hass(hass) - assert not await hass.config_entries.async_setup(entry_v2.entry_id) - assert entry_v2.state is ConfigEntryState.MIGRATION_ERROR + assert not await hass.config_entries.async_setup(entry_v3.entry_id) + assert entry_v3.state is ConfigEntryState.MIGRATION_ERROR async def test_config_flow_entry_migration_downgrade( hass: HomeAssistant, ) -> None: """Test that config entry fails setup if the version is from the future.""" - entry = MockConfigEntry(domain=DOMAIN, version=4) + entry = MockConfigEntry(domain=DOMAIN, version=5) entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/lawn_mower/test_intent.py b/tests/components/lawn_mower/test_intent.py new file mode 100644 index 00000000000..81f64c3cffe --- /dev/null +++ b/tests/components/lawn_mower/test_intent.py @@ -0,0 +1,121 @@ +"""The tests for the lawn mower platform.""" + +from homeassistant.components.lawn_mower import ( + DOMAIN, + SERVICE_DOCK, + SERVICE_START_MOWING, + LawnMowerActivity, + LawnMowerEntityFeature, + intent as lawn_mower_intent, +) +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from tests.common import async_mock_service + + +async def test_start_lawn_mower_intent(hass: HomeAssistant) -> None: + """Test HassLawnMowerStartMowing intent for lawn mowers.""" + await lawn_mower_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_lawn_mower" + hass.states.async_set( + entity_id, + LawnMowerActivity.DOCKED, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.START_MOWING}, + ) + calls = async_mock_service(hass, DOMAIN, SERVICE_START_MOWING) + + response = await intent.async_handle( + hass, + "test", + lawn_mower_intent.INTENT_LANW_MOWER_START_MOWING, + {"name": {"value": "test lawn mower"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_START_MOWING + assert call.data == {"entity_id": entity_id} + + +async def test_start_lawn_mower_without_name(hass: HomeAssistant) -> None: + """Test starting a lawn mower without specifying the name.""" + await lawn_mower_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_lawn_mower" + hass.states.async_set( + entity_id, + LawnMowerActivity.DOCKED, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.START_MOWING}, + ) + calls = async_mock_service(hass, DOMAIN, SERVICE_START_MOWING) + + response = await intent.async_handle( + hass, "test", lawn_mower_intent.INTENT_LANW_MOWER_START_MOWING, {} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_START_MOWING + assert call.data == {"entity_id": entity_id} + + +async def test_stop_lawn_mower_intent(hass: HomeAssistant) -> None: + """Test HassLawnMowerDock intent for lawn mowers.""" + await lawn_mower_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_lawn_mower" + hass.states.async_set( + entity_id, + LawnMowerActivity.MOWING, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK}, + ) + calls = async_mock_service(hass, DOMAIN, SERVICE_DOCK) + + response = await intent.async_handle( + hass, + "test", + lawn_mower_intent.INTENT_LANW_MOWER_DOCK, + {"name": {"value": "test lawn mower"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_DOCK + assert call.data == {"entity_id": entity_id} + + +async def test_stop_lawn_mower_without_name(hass: HomeAssistant) -> None: + """Test stopping a lawn mower without specifying the name.""" + await lawn_mower_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_lawn_mower" + hass.states.async_set( + entity_id, + LawnMowerActivity.MOWING, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK}, + ) + calls = async_mock_service(hass, DOMAIN, SERVICE_DOCK) + + response = await intent.async_handle( + hass, "test", lawn_mower_intent.INTENT_LANW_MOWER_DOCK, {} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_DOCK + assert call.data == {"entity_id": entity_id} diff --git a/tests/components/letpot/snapshots/test_number.ambr b/tests/components/letpot/snapshots/test_number.ambr new file mode 100644 index 00000000000..4784cfa695a --- /dev/null +++ b/tests/components/letpot/snapshots/test_number.ambr @@ -0,0 +1,116 @@ +# serializer version: 1 +# name: test_all_entities[number.garden_light_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8.0, + 'min': 1.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.garden_light_brightness', + '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': 'Light brightness', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_brightness', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_brightness_levels', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.garden_light_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light brightness', + 'max': 8.0, + 'min': 1.0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.garden_light_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[number.garden_plants_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.garden_plants_age', + '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': 'Plants age', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plant_days', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_plant_days', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.garden_plants_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Plants age', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.garden_plants_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/letpot/test_number.py b/tests/components/letpot/test_number.py new file mode 100644 index 00000000000..423ac7c3194 --- /dev/null +++ b/tests/components/letpot/test_number.py @@ -0,0 +1,99 @@ +"""Test number entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test number entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_number( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + device_type: str, +) -> None: + """Test number entity set to value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.garden_light_brightness", + ATTR_VALUE: 6, + }, + blocking=True, + ) + + mock_device_client.set_light_brightness.assert_awaited_once_with( + f"{device_type}ABCD", 750 + ) + + +@pytest.mark.parametrize( + ("exception", "user_error"), + [ + ( + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_number_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test number entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_plant_days.side_effect = exception + + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.garden_plants_age", + ATTR_VALUE: 7, + }, + blocking=True, + ) diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index 73abc8c5075..a0626ddb603 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -118,13 +118,21 @@ def mock_thinq_mqtt_client() -> Generator[None]: "washer", ] ) -def device_fixture( - mock_thinq_api: AsyncMock, request: pytest.FixtureRequest -) -> Generator[str]: +def device_fixture(request: pytest.FixtureRequest) -> Generator[str]: """Return every device.""" return request.param +def energy_fixture(request: pytest.FixtureRequest) -> Generator[str]: + """Return energy period.""" + return request.param + + +def energy_usage(request: pytest.FixtureRequest) -> Generator[str]: + """Return energy usage per period.""" + return request.param + + @pytest.fixture def devices(mock_thinq_api: AsyncMock, device_fixture: str) -> Generator[AsyncMock]: """Return a specific device.""" @@ -137,4 +145,8 @@ def devices(mock_thinq_api: AsyncMock, device_fixture: str) -> Generator[AsyncMo mock_thinq_api.async_get_device_status.return_value = load_json_object_fixture( f"{device_fixture}/status.json", DOMAIN ) + mock_thinq_api.async_get_device_energy_profile.return_value = ( + load_json_object_fixture(f"{device_fixture}/energy_profile.json", DOMAIN) + ) + mock_thinq_api.async_get_route.return_value = MagicMock() return mock_thinq_api diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/energy_last_month.json b/tests/components/lg_thinq/fixtures/air_conditioner/energy_last_month.json new file mode 100644 index 00000000000..18eea27aedb --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/energy_last_month.json @@ -0,0 +1,7 @@ +{ + "resultCode": "0000", + "result": { + "dataList": [{ "energyUsage": 700.0, "usedDate": "202409" }], + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/energy_profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/energy_profile.json new file mode 100644 index 00000000000..b2792613644 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/energy_profile.json @@ -0,0 +1,6 @@ +{ + "resultCode": "0000", + "result": { + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/energy_this_month.json b/tests/components/lg_thinq/fixtures/air_conditioner/energy_this_month.json new file mode 100644 index 00000000000..0dc2d46724b --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/energy_this_month.json @@ -0,0 +1,7 @@ +{ + "resultCode": "0000", + "result": { + "dataList": [{ "energyUsage": 500.0, "usedDate": "202410" }], + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/energy_yesterday.json b/tests/components/lg_thinq/fixtures/air_conditioner/energy_yesterday.json new file mode 100644 index 00000000000..e386c106897 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/energy_yesterday.json @@ -0,0 +1,7 @@ +{ + "resultCode": "0000", + "result": { + "dataList": [{ "energyUsage": 100.0, "usedDate": "20241009" }], + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/fixtures/washer/energy_profile.json b/tests/components/lg_thinq/fixtures/washer/energy_profile.json new file mode 100644 index 00000000000..b2792613644 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/washer/energy_profile.json @@ -0,0 +1,6 @@ +{ + "resultCode": "0000", + "result": { + "property": ["energyUsage"] + } +} diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index 5c05244b313..513405d1005 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -54,7 +54,7 @@ 'platform': 'lg_thinq', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -84,7 +84,7 @@ 'none', 'air_clean', ]), - 'supported_features': , + 'supported_features': , 'swing_horizontal_mode': 'off', 'swing_horizontal_modes': list([ 'on', @@ -95,8 +95,6 @@ 'on', 'off', ]), - 'target_temp_high': None, - 'target_temp_low': None, 'target_temp_step': 2, 'temperature': 66, }), diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 1ab4ede5a5b..4bf3609bdfc 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -415,3 +415,171 @@ 'state': '2024-10-10T13:14:00+00:00', }) # --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_yesterday-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.test_air_conditioner_energy_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy yesterday', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_yesterday', + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_energyUsage_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test air conditioner Energy yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_energy_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_this_month-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.test_air_conditioner_energy_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy this month', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_this_month', + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_energyUsage_this_month', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test air conditioner Energy this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_energy_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_last_month-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.test_air_conditioner_energy_last_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy last month', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_last_month', + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_energyUsage_last_month', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_energy_last_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test air conditioner Energy last month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_energy_last_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- \ No newline at end of file diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index 87f03de6c0d..fa986c37f48 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -1,18 +1,26 @@ """Tests for the LG Thinq sensor platform.""" -from datetime import UTC, datetime +from datetime import UTC, datetime, time, timedelta from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.lg_thinq.const import DOMAIN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, + snapshot_platform, +) @pytest.mark.parametrize("device_fixture", ["air_conditioner"]) @@ -22,7 +30,6 @@ async def test_sensor_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, devices: AsyncMock, - mock_thinq_api: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: @@ -32,3 +39,46 @@ async def test_sensor_entities( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device_fixture", "energy_fixture", "energy_usage"), + [ + ("air_conditioner", "yesterday", 100), + ("air_conditioner", "this_month", 500), + ("air_conditioner", "last_month", 700), + ], +) +@pytest.mark.freeze_time(datetime(2024, 10, 9, 10, 0, tzinfo=UTC)) +async def test_update_energy_entity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_thinq_api: AsyncMock, + device_fixture: str, + energy_fixture: str, + energy_usage: int, + freezer: FrozenDateTimeFactory, +) -> None: + """Test update energy entity.""" + hass.config.time_zone = "UTC" + with patch( + "homeassistant.components.lg_thinq.sensor.random.randint", return_value=1 + ): + await setup_integration(hass, mock_config_entry) + + entity_id = f"sensor.test_{device_fixture}_energy_{energy_fixture}" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN + + mock_thinq_api.async_get_device_energy_usage.return_value = ( + await async_load_json_object_fixture( + hass, f"{device_fixture}/energy_{energy_fixture}.json", DOMAIN + ) + ) + freezer.move_to(datetime.combine(utcnow() + timedelta(days=1), time(1, 1))) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert float(state.state) == energy_usage diff --git a/tests/components/libre_hardware_monitor/__init__.py b/tests/components/libre_hardware_monitor/__init__.py new file mode 100644 index 00000000000..5038f95219f --- /dev/null +++ b/tests/components/libre_hardware_monitor/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the LibreHardwareMonitor integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Mock integration setup.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/libre_hardware_monitor/conftest.py b/tests/components/libre_hardware_monitor/conftest.py new file mode 100644 index 00000000000..cff9e4acf3a --- /dev/null +++ b/tests/components/libre_hardware_monitor/conftest.py @@ -0,0 +1,57 @@ +"""Common fixtures for the LibreHardwareMonitor tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from librehardwaremonitor_api.parser import LibreHardwareMonitorParser +import pytest + +from homeassistant.components.libre_hardware_monitor.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry, load_json_object_fixture + +VALID_CONFIG = {CONF_HOST: "192.168.0.20", CONF_PORT: 8085} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.libre_hardware_monitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Config entry fixture.""" + return MockConfigEntry( + domain=DOMAIN, + title="192.168.0.20:8085", + data=VALID_CONFIG, + ) + + +@pytest.fixture +def mock_lhm_client() -> Generator[AsyncMock]: + """Mock a LibreHardwareMonitor client.""" + with ( + patch( + "homeassistant.components.libre_hardware_monitor.config_flow.LibreHardwareMonitorClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.libre_hardware_monitor.coordinator.LibreHardwareMonitorClient", + new=mock_client, + ), + ): + client = mock_client.return_value + test_data_json = load_json_object_fixture( + "libre_hardware_monitor.json", "libre_hardware_monitor" + ) + test_data = LibreHardwareMonitorParser().parse_data(test_data_json) + client.get_data.return_value = test_data + + yield client diff --git a/tests/components/libre_hardware_monitor/fixtures/libre_hardware_monitor.json b/tests/components/libre_hardware_monitor/fixtures/libre_hardware_monitor.json new file mode 100644 index 00000000000..0e4c6309ba3 --- /dev/null +++ b/tests/components/libre_hardware_monitor/fixtures/libre_hardware_monitor.json @@ -0,0 +1,465 @@ +{ + "id": 0, + "Text": "Sensor", + "Min": "Min", + "Value": "Value", + "Max": "Max", + "ImageURL": "", + "Children": [ + { + "id": 1, + "Text": "GAMING", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/computer.png", + "Children": [ + { + "id": 2, + "Text": "MSI MAG B650M MORTAR WIFI (MS-7D76)", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/mainboard.png", + "Children": [ + { + "id": 3, + "Text": "Nuvoton NCT6687D", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/chip.png", + "Children": [ + { + "id": 4, + "Text": "Voltages", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/voltage.png", + "Children": [ + { + "id": 5, + "Text": "+12V", + "Min": "12,048 V", + "Value": "12,072 V", + "Max": "12,096 V", + "SensorId": "/lpc/nct6687d/0/voltage/0", + "Type": "Voltage", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 6, + "Text": "+5V", + "Min": "5,020 V", + "Value": "5,030 V", + "Max": "5,050 V", + "SensorId": "/lpc/nct6687d/0/voltage/1", + "Type": "Voltage", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 7, + "Text": "Vcore", + "Min": "1,310 V", + "Value": "1,312 V", + "Max": "1,318 V", + "SensorId": "/lpc/nct6687d/0/voltage/2", + "Type": "Voltage", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 8, + "Text": "Temperatures", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/temperature.png", + "Children": [ + { + "id": 9, + "Text": "CPU", + "Min": "39,0 °C", + "Value": "55,0 °C", + "Max": "68,0 °C", + "SensorId": "/lpc/nct6687d/0/temperature/0", + "Type": "Temperature", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 10, + "Text": "System", + "Min": "32,5 °C", + "Value": "45,5 °C", + "Max": "46,5 °C", + "SensorId": "/lpc/nct6687d/0/temperature/1", + "Type": "Temperature", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 11, + "Text": "Fans", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/fan.png", + "Children": [ + { + "id": 12, + "Text": "CPU Fan", + "Min": "0 RPM", + "Value": "0 RPM", + "Max": "0 RPM", + "SensorId": "/lpc/nct6687d/0/fan/0", + "Type": "Fan", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 13, + "Text": "Pump Fan", + "Min": "0 RPM", + "Value": "0 RPM", + "Max": "0 RPM", + "SensorId": "/lpc/nct6687d/0/fan/1", + "Type": "Fan", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 14, + "Text": "System Fan #1", + "Min": "-", + "Value": "-", + "Max": "-", + "SensorId": "/lpc/nct6687d/0/fan/2", + "Type": "Fan", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + } + ] + } + ] + }, + { + "id": 15, + "Text": "AMD Ryzen 7 7800X3D", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/cpu.png", + "Children": [ + { + "id": 16, + "Text": "Voltages", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/voltage.png", + "Children": [ + { + "id": 17, + "Text": "VDDCR", + "Min": "0,452 V", + "Value": "1,083 V", + "Max": "1,173 V", + "SensorId": "/amdcpu/0/voltage/2", + "Type": "Voltage", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 18, + "Text": "VDDCR SoC", + "Min": "1,305 V", + "Value": "1,305 V", + "Max": "1,306 V", + "SensorId": "/amdcpu/0/voltage/3", + "Type": "Voltage", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 19, + "Text": "Powers", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/power.png", + "Children": [ + { + "id": 20, + "Text": "Package", + "Min": "25,1 W", + "Value": "39,6 W", + "Max": "70,1 W", + "SensorId": "/amdcpu/0/power/0", + "Type": "Power", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 21, + "Text": "Temperatures", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/temperature.png", + "Children": [ + { + "id": 22, + "Text": "Core (Tctl/Tdie)", + "Min": "39,4 °C", + "Value": "55,5 °C", + "Max": "69,1 °C", + "SensorId": "/amdcpu/0/temperature/2", + "Type": "Temperature", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 23, + "Text": "Package", + "Min": "38,4 °C", + "Value": "52,8 °C", + "Max": "74,0 °C", + "SensorId": "/amdcpu/0/temperature/3", + "Type": "Temperature", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 24, + "Text": "Load", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/load.png", + "Children": [ + { + "id": 25, + "Text": "CPU Total", + "Min": "0,0 %", + "Value": "9,1 %", + "Max": "55,8 %", + "SensorId": "/amdcpu/0/load/0", + "Type": "Load", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + } + ] + }, + { + "id": 26, + "Text": "NVIDIA GeForce RTX 4080 SUPER", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/nvidia.png", + "Children": [ + { + "id": 27, + "Text": "Powers", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/power.png", + "Children": [ + { + "id": 28, + "Text": "GPU Package", + "Min": "4,1 W", + "Value": "59,6 W", + "Max": "66,6 W", + "SensorId": "/gpu-nvidia/0/power/0", + "Type": "Power", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 29, + "Text": "Clocks", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/clock.png", + "Children": [ + { + "id": 30, + "Text": "GPU Core", + "Min": "210,0 MHz", + "Value": "2805,0 MHz", + "Max": "2805,0 MHz", + "SensorId": "/gpu-nvidia/0/clock/0", + "Type": "Clock", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 31, + "Text": "GPU Memory", + "Min": "405,0 MHz", + "Value": "11252,0 MHz", + "Max": "11502,0 MHz", + "SensorId": "/gpu-nvidia/0/clock/4", + "Type": "Clock", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 32, + "Text": "Temperatures", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/temperature.png", + "Children": [ + { + "id": 33, + "Text": "GPU Core", + "Min": "25,0 °C", + "Value": "36,0 °C", + "Max": "37,0 °C", + "SensorId": "/gpu-nvidia/0/temperature/0", + "Type": "Temperature", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 34, + "Text": "GPU Hot Spot", + "Min": "32,5 °C", + "Value": "43,0 °C", + "Max": "43,3 °C", + "SensorId": "/gpu-nvidia/0/temperature/2", + "Type": "Temperature", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 35, + "Text": "Load", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/load.png", + "Children": [ + { + "id": 36, + "Text": "GPU Core", + "Min": "0,0 %", + "Value": "5,0 %", + "Max": "19,0 %", + "SensorId": "/gpu-nvidia/0/load/0", + "Type": "Load", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 37, + "Text": "GPU Memory Controller", + "Min": "0,0 %", + "Value": "0,0 %", + "Max": "49,0 %", + "SensorId": "/gpu-nvidia/0/load/1", + "Type": "Load", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 38, + "Text": "GPU Video Engine", + "Min": "0,0 %", + "Value": "97,0 %", + "Max": "99,0 %", + "SensorId": "/gpu-nvidia/0/load/2", + "Type": "Load", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 39, + "Text": "Fans", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/fan.png", + "Children": [ + { + "id": 40, + "Text": "GPU Fan 1", + "Min": "0 RPM", + "Value": "0 RPM", + "Max": "0 RPM", + "SensorId": "/gpu-nvidia/0/fan/1", + "Type": "Fan", + "ImageURL": "images/transparent.png", + "Children": [] + }, + { + "id": 41, + "Text": "GPU Fan 2", + "Min": "0 RPM", + "Value": "0 RPM", + "Max": "0 RPM", + "SensorId": "/gpu-nvidia/0/fan/2", + "Type": "Fan", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + }, + { + "id": 42, + "Text": "Throughput", + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/throughput.png", + "Children": [ + { + "id": 43, + "Text": "GPU PCIe Tx", + "Min": "0,0 KB/s", + "Value": "166,1 MB/s", + "Max": "2422,8 MB/s", + "SensorId": "/gpu-nvidia/0/throughput/1", + "Type": "Throughput", + "ImageURL": "images/transparent.png", + "Children": [] + } + ] + } + ] + } + ] + } + ] +} diff --git a/tests/components/libre_hardware_monitor/snapshots/test_sensor.ambr b/tests/components/libre_hardware_monitor/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9e26d4d49f7 --- /dev/null +++ b/tests/components/libre_hardware_monitor/snapshots/test_sensor.ambr @@ -0,0 +1,2106 @@ +# serializer version: 1 +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_core_tctl_tdie_temperature-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.amd_ryzen_7_7800x3d_core_tctl_tdie_temperature', + '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': 'Core (Tctl/Tdie) Temperature', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-amdcpu-0-temperature-2', + 'unit_of_measurement': '°C', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_core_tctl_tdie_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Core (Tctl/Tdie) Temperature', + 'max_value': '69.1', + 'min_value': '39.4', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_core_tctl_tdie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.5', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_cpu_total_load-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.amd_ryzen_7_7800x3d_cpu_total_load', + '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': 'CPU Total Load', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-amdcpu-0-load-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_cpu_total_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D CPU Total Load', + 'max_value': '55.8', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_cpu_total_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.1', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_package_power-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.amd_ryzen_7_7800x3d_package_power', + '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': 'Package Power', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-amdcpu-0-power-0', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_package_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Package Power', + 'max_value': '70.1', + 'min_value': '25.1', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_package_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.6', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_package_temperature-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.amd_ryzen_7_7800x3d_package_temperature', + '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': 'Package Temperature', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-amdcpu-0-temperature-3', + 'unit_of_measurement': '°C', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_package_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Package Temperature', + 'max_value': '74.0', + 'min_value': '38.4', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_package_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.8', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_vddcr_soc_voltage-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.amd_ryzen_7_7800x3d_vddcr_soc_voltage', + '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': 'VDDCR SoC Voltage', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-amdcpu-0-voltage-3', + 'unit_of_measurement': 'V', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_vddcr_soc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D VDDCR SoC Voltage', + 'max_value': '1.306', + 'min_value': '1.305', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_vddcr_soc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.305', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_vddcr_voltage-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.amd_ryzen_7_7800x3d_vddcr_voltage', + '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': 'VDDCR Voltage', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-amdcpu-0-voltage-2', + 'unit_of_measurement': 'V', + }) +# --- +# name: test_sensors_are_created[sensor.amd_ryzen_7_7800x3d_vddcr_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D VDDCR Voltage', + 'max_value': '1.173', + 'min_value': '0.452', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_vddcr_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.083', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_12v_voltage-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.msi_mag_b650m_mortar_wifi_ms_7d76_12v_voltage', + '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': '+12V Voltage', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-lpc-nct6687d-0-voltage-0', + 'unit_of_measurement': 'V', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_12v_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) +12V Voltage', + 'max_value': '12.096', + 'min_value': '12.048', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_12v_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.072', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_5v_voltage-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.msi_mag_b650m_mortar_wifi_ms_7d76_5v_voltage', + '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': '+5V Voltage', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-lpc-nct6687d-0-voltage-1', + 'unit_of_measurement': 'V', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_5v_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) +5V Voltage', + 'max_value': '5.050', + 'min_value': '5.020', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_5v_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.030', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan-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.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan', + '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': 'CPU Fan Fan', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-lpc-nct6687d-0-fan-0', + 'unit_of_measurement': 'RPM', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature-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.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature', + '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': 'CPU Temperature', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-lpc-nct6687d-0-temperature-0', + 'unit_of_measurement': '°C', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Temperature', + 'max_value': '68.0', + 'min_value': '39.0', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.0', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan-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.msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan', + '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': 'Pump Fan Fan', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-lpc-nct6687d-0-fan-1', + 'unit_of_measurement': 'RPM', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan-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.msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan', + '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': 'System Fan #1 Fan', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-lpc-nct6687d-0-fan-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan', + 'max_value': '-', + 'min_value': '-', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature-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.msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature', + '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': 'System Temperature', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-lpc-nct6687d-0-temperature-1', + 'unit_of_measurement': '°C', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Temperature', + 'max_value': '46.5', + 'min_value': '32.5', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.5', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_vcore_voltage-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.msi_mag_b650m_mortar_wifi_ms_7d76_vcore_voltage', + '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': 'Vcore Voltage', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-lpc-nct6687d-0-voltage-2', + 'unit_of_measurement': 'V', + }) +# --- +# name: test_sensors_are_created[sensor.msi_mag_b650m_mortar_wifi_ms_7d76_vcore_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) Vcore Voltage', + 'max_value': '1.318', + 'min_value': '1.310', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_vcore_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.312', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_core_clock-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.nvidia_geforce_rtx_4080_super_gpu_core_clock', + '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': 'GPU Core Clock', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-clock-0', + 'unit_of_measurement': 'MHz', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_core_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Clock', + 'max_value': '2805.0', + 'min_value': '210.0', + 'state_class': , + 'unit_of_measurement': 'MHz', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2805.0', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_core_load-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.nvidia_geforce_rtx_4080_super_gpu_core_load', + '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': 'GPU Core Load', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-load-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_core_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Load', + 'max_value': '19.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_core_temperature-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.nvidia_geforce_rtx_4080_super_gpu_core_temperature', + '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': 'GPU Core Temperature', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-temperature-0', + 'unit_of_measurement': '°C', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Temperature', + 'max_value': '37.0', + 'min_value': '25.0', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.0', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_fan_1_fan-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.nvidia_geforce_rtx_4080_super_gpu_fan_1_fan', + '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': 'GPU Fan 1 Fan', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-fan-1', + 'unit_of_measurement': 'RPM', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_fan_1_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_fan_1_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_fan_2_fan-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.nvidia_geforce_rtx_4080_super_gpu_fan_2_fan', + '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': 'GPU Fan 2 Fan', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-fan-2', + 'unit_of_measurement': 'RPM', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_fan_2_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_fan_2_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature-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.nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature', + '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': 'GPU Hot Spot Temperature', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-temperature-2', + 'unit_of_measurement': '°C', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Hot Spot Temperature', + 'max_value': '43.3', + 'min_value': '32.5', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.0', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_memory_clock-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.nvidia_geforce_rtx_4080_super_gpu_memory_clock', + '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': 'GPU Memory Clock', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-clock-4', + 'unit_of_measurement': 'MHz', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_memory_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Memory Clock', + 'max_value': '11502.0', + 'min_value': '405.0', + 'state_class': , + 'unit_of_measurement': 'MHz', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_memory_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11252.0', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_memory_controller_load-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.nvidia_geforce_rtx_4080_super_gpu_memory_controller_load', + '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': 'GPU Memory Controller Load', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-load-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_memory_controller_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Memory Controller Load', + 'max_value': '49.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_memory_controller_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_package_power-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.nvidia_geforce_rtx_4080_super_gpu_package_power', + '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': 'GPU Package Power', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-power-0', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_package_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Package Power', + 'max_value': '66.6', + 'min_value': '4.1', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_package_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59.6', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput-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.nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput', + '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': 'GPU PCIe Tx Throughput', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-throughput-1', + 'unit_of_measurement': 'MB/s', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU PCIe Tx Throughput', + 'max_value': '2422.8', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': 'MB/s', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '166.1', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_video_engine_load-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.nvidia_geforce_rtx_4080_super_gpu_video_engine_load', + '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': 'GPU Video Engine Load', + 'platform': 'libre_hardware_monitor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lhm-gpu-nvidia-0-load-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors_are_created[sensor.nvidia_geforce_rtx_4080_super_gpu_video_engine_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Video Engine Load', + 'max_value': '99.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_video_engine_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_sensors_go_unavailable_in_case_of_error_and_recover_after_successful_retry[LibreHardwareMonitorConnectionError][valid_sensor_data] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) +12V Voltage', + 'max_value': '12.096', + 'min_value': '12.048', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_12v_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.072', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) +5V Voltage', + 'max_value': '5.050', + 'min_value': '5.020', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_5v_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.030', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) Vcore Voltage', + 'max_value': '1.318', + 'min_value': '1.310', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_vcore_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.312', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Temperature', + 'max_value': '68.0', + 'min_value': '39.0', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Temperature', + 'max_value': '46.5', + 'min_value': '32.5', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan', + 'max_value': '-', + 'min_value': '-', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D VDDCR Voltage', + 'max_value': '1.173', + 'min_value': '0.452', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_vddcr_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.083', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D VDDCR SoC Voltage', + 'max_value': '1.306', + 'min_value': '1.305', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_vddcr_soc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.305', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Package Power', + 'max_value': '70.1', + 'min_value': '25.1', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_package_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.6', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Core (Tctl/Tdie) Temperature', + 'max_value': '69.1', + 'min_value': '39.4', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_core_tctl_tdie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Package Temperature', + 'max_value': '74.0', + 'min_value': '38.4', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_package_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.8', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D CPU Total Load', + 'max_value': '55.8', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_cpu_total_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.1', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Package Power', + 'max_value': '66.6', + 'min_value': '4.1', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_package_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59.6', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Clock', + 'max_value': '2805.0', + 'min_value': '210.0', + 'state_class': , + 'unit_of_measurement': 'MHz', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2805.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Memory Clock', + 'max_value': '11502.0', + 'min_value': '405.0', + 'state_class': , + 'unit_of_measurement': 'MHz', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_memory_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11252.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Temperature', + 'max_value': '37.0', + 'min_value': '25.0', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Hot Spot Temperature', + 'max_value': '43.3', + 'min_value': '32.5', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Load', + 'max_value': '19.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Memory Controller Load', + 'max_value': '49.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_memory_controller_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Video Engine Load', + 'max_value': '99.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_video_engine_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_fan_1_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_fan_2_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU PCIe Tx Throughput', + 'max_value': '2422.8', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': 'MB/s', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '166.1', + }), + ]) +# --- +# name: test_sensors_go_unavailable_in_case_of_error_and_recover_after_successful_retry[LibreHardwareMonitorNoDevicesError][valid_sensor_data] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) +12V Voltage', + 'max_value': '12.096', + 'min_value': '12.048', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_12v_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.072', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) +5V Voltage', + 'max_value': '5.050', + 'min_value': '5.020', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_5v_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.030', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) Vcore Voltage', + 'max_value': '1.318', + 'min_value': '1.310', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_vcore_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.312', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Temperature', + 'max_value': '68.0', + 'min_value': '39.0', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Temperature', + 'max_value': '46.5', + 'min_value': '32.5', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan', + 'max_value': '-', + 'min_value': '-', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D VDDCR Voltage', + 'max_value': '1.173', + 'min_value': '0.452', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_vddcr_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.083', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D VDDCR SoC Voltage', + 'max_value': '1.306', + 'min_value': '1.305', + 'state_class': , + 'unit_of_measurement': 'V', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_vddcr_soc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.305', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Package Power', + 'max_value': '70.1', + 'min_value': '25.1', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_package_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.6', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Core (Tctl/Tdie) Temperature', + 'max_value': '69.1', + 'min_value': '39.4', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_core_tctl_tdie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D Package Temperature', + 'max_value': '74.0', + 'min_value': '38.4', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_package_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.8', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AMD Ryzen 7 7800X3D CPU Total Load', + 'max_value': '55.8', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.amd_ryzen_7_7800x3d_cpu_total_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.1', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Package Power', + 'max_value': '66.6', + 'min_value': '4.1', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_package_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59.6', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Clock', + 'max_value': '2805.0', + 'min_value': '210.0', + 'state_class': , + 'unit_of_measurement': 'MHz', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2805.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Memory Clock', + 'max_value': '11502.0', + 'min_value': '405.0', + 'state_class': , + 'unit_of_measurement': 'MHz', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_memory_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11252.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Temperature', + 'max_value': '37.0', + 'min_value': '25.0', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Hot Spot Temperature', + 'max_value': '43.3', + 'min_value': '32.5', + 'state_class': , + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Core Load', + 'max_value': '19.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_core_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Memory Controller Load', + 'max_value': '49.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_memory_controller_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Video Engine Load', + 'max_value': '99.0', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_video_engine_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_fan_1_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Fan', + 'max_value': '0', + 'min_value': '0', + 'state_class': , + 'unit_of_measurement': 'RPM', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_fan_2_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVIDIA GeForce RTX 4080 SUPER GPU PCIe Tx Throughput', + 'max_value': '2422.8', + 'min_value': '0.0', + 'state_class': , + 'unit_of_measurement': 'MB/s', + }), + 'context': , + 'entity_id': 'sensor.nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '166.1', + }), + ]) +# --- diff --git a/tests/components/libre_hardware_monitor/test_config_flow.py b/tests/components/libre_hardware_monitor/test_config_flow.py new file mode 100644 index 00000000000..9fcab5daeba --- /dev/null +++ b/tests/components/libre_hardware_monitor/test_config_flow.py @@ -0,0 +1,114 @@ +"""Test the LibreHardwareMonitor config flow.""" + +from unittest.mock import AsyncMock + +from librehardwaremonitor_api import ( + LibreHardwareMonitorConnectionError, + LibreHardwareMonitorNoDevicesError, +) + +from homeassistant.components.libre_hardware_monitor.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import VALID_CONFIG + +from tests.common import MockConfigEntry + + +async def test_create_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a complete config entry is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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=VALID_CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id is None + + mock_config_entry = result["result"] + assert ( + mock_config_entry.title + == f"{VALID_CONFIG[CONF_HOST]}:{VALID_CONFIG[CONF_PORT]}" + ) + assert mock_config_entry.data == VALID_CONFIG + + assert mock_setup_entry.call_count == 1 + + +async def test_errors_and_flow_recovery( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_lhm_client: AsyncMock +) -> None: + """Test that errors are shown as expected.""" + mock_lhm_client.get_data.side_effect = LibreHardwareMonitorConnectionError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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=VALID_CONFIG + ) + + assert result["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_lhm_client.get_data.side_effect = LibreHardwareMonitorNoDevicesError() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG + ) + + assert result["errors"] == {"base": "no_devices"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_lhm_client.get_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert mock_setup_entry.call_count == 1 + + +async def test_lhm_server_already_exists( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test we only allow a single entry per server.""" + 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["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_setup_entry.call_count == 0 diff --git a/tests/components/libre_hardware_monitor/test_sensor.py b/tests/components/libre_hardware_monitor/test_sensor.py new file mode 100644 index 00000000000..0ce8f5e1c8f --- /dev/null +++ b/tests/components/libre_hardware_monitor/test_sensor.py @@ -0,0 +1,212 @@ +"""Test the LibreHardwareMonitor sensor.""" + +from dataclasses import replace +from datetime import timedelta +import logging +from types import MappingProxyType +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from librehardwaremonitor_api import ( + LibreHardwareMonitorConnectionError, + LibreHardwareMonitorNoDevicesError, +) +from librehardwaremonitor_api.model import ( + DeviceId, + DeviceName, + LibreHardwareMonitorData, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.libre_hardware_monitor.const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensors_are_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensors are created.""" + await init_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "error", [LibreHardwareMonitorConnectionError, LibreHardwareMonitorNoDevicesError] +) +async def test_sensors_go_unavailable_in_case_of_error_and_recover_after_successful_retry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + error: type[Exception], +) -> None: + """Test sensors go unavailable.""" + await init_integration(hass, mock_config_entry) + + initial_states = hass.states.async_all() + assert initial_states == snapshot(name="valid_sensor_data") + + mock_lhm_client.get_data.side_effect = error + + freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + unavailable_states = hass.states.async_all() + assert all(state.state == STATE_UNAVAILABLE for state in unavailable_states) + + mock_lhm_client.get_data.side_effect = None + + freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + recovered_states = hass.states.async_all() + assert all(state.state != STATE_UNAVAILABLE for state in recovered_states) + + +async def test_sensors_are_updated( + hass: HomeAssistant, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensors are updated.""" + await init_integration(hass, mock_config_entry) + + entity_id = "sensor.amd_ryzen_7_7800x3d_package_temperature" + + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "52.8" + + updated_data = dict(mock_lhm_client.get_data.return_value.sensor_data) + updated_data["amdcpu-0-temperature-3"] = replace( + updated_data["amdcpu-0-temperature-3"], value="42,1" + ) + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, + sensor_data=MappingProxyType(updated_data), + ) + + freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "42.1" + + +async def test_sensor_state_is_unknown_when_no_sensor_data_is_provided( + hass: HomeAssistant, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor state is unknown when sensor data is missing.""" + await init_integration(hass, mock_config_entry) + + entity_id = "sensor.amd_ryzen_7_7800x3d_package_temperature" + + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "52.8" + + updated_data = dict(mock_lhm_client.get_data.return_value.sensor_data) + del updated_data["amdcpu-0-temperature-3"] + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, + sensor_data=MappingProxyType(updated_data), + ) + + freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNKNOWN + + +async def test_orphaned_devices_are_removed( + hass: HomeAssistant, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that devices in HA that do not receive updates are removed.""" + await init_integration(hass, mock_config_entry) + + mock_lhm_client.get_data.return_value = LibreHardwareMonitorData( + main_device_ids_and_names=MappingProxyType( + { + DeviceId("amdcpu-0"): DeviceName("AMD Ryzen 7 7800X3D"), + DeviceId("gpu-nvidia-0"): DeviceName("NVIDIA GeForce RTX 4080 SUPER"), + } + ), + sensor_data=mock_lhm_client.get_data.return_value.sensor_data, + ) + + device_registry = dr.async_get(hass) + orphaned_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "lpc-nct6687d-0")}, + ) + + with patch.object( + device_registry, + "async_remove_device", + wraps=device_registry.async_update_device, + ) as mock_remove: + freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_remove.assert_called_once_with(orphaned_device.id) + + +async def test_integration_does_not_log_new_devices_on_first_refresh( + hass: HomeAssistant, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that initial data update does not cause warning about new devices.""" + mock_lhm_client.get_data.return_value = LibreHardwareMonitorData( + main_device_ids_and_names=MappingProxyType( + { + **mock_lhm_client.get_data.return_value.main_device_ids_and_names, + DeviceId("generic-memory"): DeviceName("Generic Memory"), + } + ), + sensor_data=mock_lhm_client.get_data.return_value.sensor_data, + ) + + with caplog.at_level(logging.WARNING): + await init_integration(hass, mock_config_entry) + assert len(caplog.records) == 0 diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index 1b09d742876..aecc71b07e8 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -143,7 +143,11 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No ) config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_config_flow_try_connect(no_device=True): + with ( + _patch_device(), + _patch_discovery(), + _patch_config_flow_try_connect(no_device=True), + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index edb13c259e8..dff43bc21b6 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -2142,7 +2142,12 @@ async def test_light_strip_zones_not_populated_yet(hass: HomeAssistant) -> None: assert bulb.set_power.calls[0][0][0] is True bulb.set_power.reset_mock() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done() + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == STATE_ON diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py index b7ff563bdbc..96d3ec4fa4a 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components import lifx from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( @@ -32,6 +34,7 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("mock_discovery") async def test_rssi_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -54,32 +57,30 @@ async def test_rssi_sensor( await hass.async_block_till_done() entity_id = "sensor.my_bulb_rssi" + assert not hass.states.get(entity_id) entry = entity_registry.entities.get(entity_id) assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - # Test enabling entity + # Test enabling entity, this will trigger a reload of the config entry updated_entry = entity_registry.async_update_entity( entry.entity_id, disabled_by=None ) + assert updated_entry != entry + assert updated_entry.disabled is False + assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + with ( _patch_discovery(device=bulb), _patch_config_flow_try_connect(device=bulb), _patch_device(device=bulb), ): - await hass.config_entries.async_reload(config_entry.entry_id) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) await hass.async_block_till_done() - assert updated_entry != entry - assert updated_entry.disabled is False - assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS_MILLIWATT - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) - await hass.async_block_till_done() - rssi = hass.states.get(entity_id) assert ( rssi.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT @@ -88,6 +89,7 @@ async def test_rssi_sensor( assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT +@pytest.mark.usefixtures("mock_discovery") async def test_rssi_sensor_old_firmware( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -116,26 +118,23 @@ async def test_rssi_sensor_old_firmware( assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - # Test enabling entity + # Test enabling entity, this will trigger a reload of the config entry updated_entry = entity_registry.async_update_entity( entry.entity_id, disabled_by=None ) + assert updated_entry != entry + assert updated_entry.disabled is False + assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS + with ( _patch_discovery(device=bulb), _patch_config_flow_try_connect(device=bulb), _patch_device(device=bulb), ): - await hass.config_entries.async_reload(config_entry.entry_id) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) await hass.async_block_till_done() - assert updated_entry != entry - assert updated_entry.disabled is False - assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) - await hass.async_block_till_done() - rssi = hass.states.get(entity_id) assert rssi.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS assert rssi.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index 19c0c3600ea..a86c782a2eb 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -39,8 +39,9 @@ ROBOT_4_DATA = { "cleanCycleWaitTime": 15, "isKeypadLockout": False, "nightLightMode": "OFF", - "nightLightBrightness": 85, + "nightLightBrightness": 50, "isPanelSleepMode": False, + "panelBrightnessHigh": 50, "panelSleepTime": 0, "panelWakeTime": 0, "weekdaySleepModeEnabled": { @@ -128,6 +129,25 @@ FEEDER_ROBOT_DATA = { "mealInsertSize": 1, }, "updated_at": "2022-09-08T15:07:00.000000+00:00", + "active_schedule": { + "id": "1", + "name": "Feeding", + "meals": [ + { + "id": "1", + "days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + "hour": 6, + "name": "Breakfast", + "skip": None, + "minute": 30, + "paused": False, + "portions": 3, + "mealNumber": 1, + "scheduleId": None, + } + ], + "created_at": "2021-12-17T07:07:31.047747+00:00", + }, }, "feeding_snack": [ {"timestamp": "2022-09-04T03:03:00.000000+00:00", "amount": 0.125}, diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index aa67db23d89..f13d0f82d2b 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -39,6 +39,7 @@ def create_mock_robot( robot = LitterRobot4(data={**ROBOT_4_DATA, **robot_data}, account=account) elif feeder: robot = FeederRobot(data={**FEEDER_ROBOT_DATA, **robot_data}, account=account) + robot.set_gravity_mode = AsyncMock(side_effect=side_effect) else: robot = LitterRobot3(data={**ROBOT_DATA, **robot_data}, account=account) robot.start_cleaning = AsyncMock(side_effect=side_effect) @@ -83,6 +84,9 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) + account.get_robots = lambda robot_class: [ + robot for robot in account.robots if isinstance(robot, robot_class) + ] account.pets = [create_mock_pet(PET_DATA, account, side_effect)] if pet else [] return account diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index b4902a56e63..873e65b33ff 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -19,7 +19,6 @@ from homeassistant.helpers import entity_registry as er from .conftest import setup_integration SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" -PANEL_BRIGHTNESS_ENTITY_ID = "select.test_panel_brightness" async def test_wait_time_select( @@ -69,26 +68,38 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No assert not mock_account.robots[0].set_wait_time.called -async def test_panel_brightness_select( +@pytest.mark.parametrize( + ("entity_id", "initial_value", "robot_command"), + [ + ("select.test_globe_brightness", "medium", "set_night_light_brightness"), + ("select.test_globe_light", "off", "set_night_light_mode"), + ("select.test_panel_brightness", "medium", "set_panel_brightness"), + ], +) +async def test_litterrobot_4_select( hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock, entity_registry: er.EntityRegistry, + entity_id: str, + initial_value: str, + robot_command: str, ) -> None: - """Tests the wait time select entity.""" + """Tests a Litter-Robot 4 select entity.""" await setup_integration(hass, mock_account_with_litterrobot_4, SELECT_DOMAIN) - select = hass.states.get(PANEL_BRIGHTNESS_ENTITY_ID) + select = hass.states.get(entity_id) assert select assert len(select.attributes[ATTR_OPTIONS]) == 3 + assert select.state == initial_value - entity_entry = entity_registry.async_get(PANEL_BRIGHTNESS_ENTITY_ID) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG - data = {ATTR_ENTITY_ID: PANEL_BRIGHTNESS_ENTITY_ID} + data = {ATTR_ENTITY_ID: entity_id} robot: LitterRobot4 = mock_account_with_litterrobot_4.robots[0] - robot.set_panel_brightness = AsyncMock(return_value=True) + setattr(robot, robot_command, AsyncMock(return_value=True)) for count, option in enumerate(select.attributes[ATTR_OPTIONS]): data[ATTR_OPTION] = option @@ -100,4 +111,4 @@ async def test_panel_brightness_select( blocking=True, ) - assert robot.set_panel_brightness.call_count == count + 1 + assert getattr(robot, robot_command).call_count == count + 1 diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index d1101a4231d..b6ce4d60954 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -104,6 +104,7 @@ async def test_litter_robot_sensor( assert sensor.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING +@pytest.mark.freeze_time("2022-09-08 19:00:00+00:00") async def test_feeder_robot_sensor( hass: HomeAssistant, mock_account_with_feederrobot: MagicMock ) -> None: @@ -113,6 +114,20 @@ async def test_feeder_robot_sensor( assert sensor.state == "10" assert sensor.attributes["unit_of_measurement"] == PERCENTAGE + sensor = hass.states.get("sensor.test_last_feeding") + assert sensor.state == "2022-09-08T18:00:00+00:00" + assert sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP + + sensor = hass.states.get("sensor.test_next_feeding") + assert sensor.state == "2022-09-09T12:30:00+00:00" + assert sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP + + sensor = hass.states.get("sensor.test_food_dispensed_today") + assert sensor.state == "0.375" + assert sensor.attributes["last_reset"] == "2022-09-08T00:00:00-07:00" + assert sensor.attributes["state_class"] == SensorStateClass.TOTAL + assert sensor.attributes["unit_of_measurement"] == "cups" + async def test_pet_weight_sensor( hass: HomeAssistant, mock_account_with_pet: MagicMock diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index d81c02bee49..3991bdbbab0 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -2,9 +2,10 @@ from unittest.mock import MagicMock -from pylitterbot import Robot +from pylitterbot import FeederRobot, Robot import pytest +from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.switch import ( DOMAIN as PLATFORM_DOMAIN, SERVICE_TURN_OFF, @@ -12,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from .conftest import setup_integration @@ -66,3 +67,71 @@ async def test_on_off_commands( assert getattr(robot, robot_command).call_count == count + 1 assert (state := hass.states.get(entity_id)) assert state.state == new_state + + +async def test_feeder_robot_switch( + hass: HomeAssistant, mock_account_with_feederrobot: MagicMock +) -> None: + """Tests Feeder-Robot switches.""" + await setup_integration(hass, mock_account_with_feederrobot, PLATFORM_DOMAIN) + robot: FeederRobot = mock_account_with_feederrobot.robots[0] + + gravity_mode_switch = "switch.test_gravity_mode" + + switch = hass.states.get(gravity_mode_switch) + assert switch.state == STATE_OFF + + data = {ATTR_ENTITY_ID: gravity_mode_switch} + + services = ((SERVICE_TURN_ON, STATE_ON, True), (SERVICE_TURN_OFF, STATE_OFF, False)) + for count, (service, new_state, new_value) in enumerate(services): + await hass.services.async_call(PLATFORM_DOMAIN, service, data, blocking=True) + robot._update_data({"state": {"info": {"gravity": new_value}}}, partial=True) + + assert robot.set_gravity_mode.call_count == count + 1 + assert (state := hass.states.get(gravity_mode_switch)) + assert state.state == new_state + + +@pytest.mark.parametrize( + ("preexisting_entity", "disabled_by", "expected_entity", "expected_issue"), + [ + (True, None, True, True), + (True, er.RegistryEntryDisabler.USER, False, False), + (False, None, False, False), + ], +) +async def test_litterrobot_4_deprecated_switch( + hass: HomeAssistant, + mock_account_with_litterrobot_4: MagicMock, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + preexisting_entity: bool, + disabled_by: er.RegistryEntryDisabler, + expected_entity: bool, + expected_issue: bool, +) -> None: + """Test switch deprecation issue.""" + entity_uid = "LR4C010001-night_light_mode_enabled" + if preexisting_entity: + suggested_id = NIGHT_LIGHT_MODE_ENTITY_ID.replace(f"{PLATFORM_DOMAIN}.", "") + entity_registry.async_get_or_create( + PLATFORM_DOMAIN, + DOMAIN, + entity_uid, + suggested_object_id=suggested_id, + disabled_by=disabled_by, + ) + + await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + + assert ( + entity_registry.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) is not None + ) is expected_entity + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{entity_uid}", + ) + is not None + ) is expected_issue diff --git a/tests/components/litterrobot/test_update.py b/tests/components/litterrobot/test_update.py index b1b092e1f02..f7d7492dec8 100644 --- a/tests/components/litterrobot/test_update.py +++ b/tests/components/litterrobot/test_update.py @@ -5,9 +5,11 @@ from unittest.mock import AsyncMock, MagicMock from pylitterbot import LitterRobot4 import pytest +from homeassistant.components.litterrobot.update import RELEASE_URL from homeassistant.components.update import ( ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, + ATTR_RELEASE_URL, DOMAIN as PLATFORM_DOMAIN, SERVICE_INSTALL, UpdateDeviceClass, @@ -47,6 +49,7 @@ async def test_robot_with_no_update( assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE assert state.attributes[ATTR_INSTALLED_VERSION] == OLD_FIRMWARE assert state.attributes[ATTR_LATEST_VERSION] == OLD_FIRMWARE + assert state.attributes[ATTR_RELEASE_URL] == RELEASE_URL assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -68,6 +71,7 @@ async def test_robot_with_update( assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE assert state.attributes[ATTR_INSTALLED_VERSION] == OLD_FIRMWARE assert state.attributes[ATTR_LATEST_VERSION] == NEW_FIRMWARE + assert state.attributes[ATTR_RELEASE_URL] == RELEASE_URL robot.update_firmware = AsyncMock(return_value=False) @@ -106,6 +110,7 @@ async def test_robot_with_update_already_in_progress( assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE assert state.attributes[ATTR_INSTALLED_VERSION] == OLD_FIRMWARE assert state.attributes[ATTR_LATEST_VERSION] is None + assert state.attributes[ATTR_RELEASE_URL] == RELEASE_URL assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 510034a2172..292f1ebd26f 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -2,13 +2,11 @@ from __future__ import annotations -from enum import Enum import re from typing import Any import pytest -from homeassistant.components import lock from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, @@ -26,8 +24,6 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .conftest import MockLock -from tests.common import help_test_all, import_and_test_deprecated_constant_enum - async def help_test_async_lock_service( hass: HomeAssistant, @@ -382,38 +378,3 @@ async def test_lock_with_illegal_default_code( == rf"The code for lock.test_lock doesn't match pattern ^\d{{{4}}}$" ) assert exc.value.translation_key == "add_default_code" - - -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(lock) - - -def _create_tuples( - enum: type[Enum], constant_prefix: str, remove_in_version: str -) -> list[tuple[Enum, str]]: - return [ - (enum_field, constant_prefix, remove_in_version) - for enum_field in enum - if enum_field - not in [ - lock.LockState.OPEN, - lock.LockState.OPENING, - ] - ] - - -@pytest.mark.parametrize( - ("enum", "constant_prefix", "remove_in_version"), - _create_tuples(lock.LockState, "STATE_", "2025.10"), -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, - remove_in_version: str, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, lock, enum, constant_prefix, remove_in_version - ) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 7b2550ccc82..4c88a5874a3 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -16,8 +16,10 @@ from homeassistant.components.logbook import websocket_api from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.util import get_instance from homeassistant.components.script import EVENT_SCRIPT_STARTED +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorDeviceClass from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -310,13 +312,15 @@ async def test_get_events_entities_filtered_away( hass.states.async_set("light.kitchen", STATE_ON) await hass.async_block_till_done() hass.states.async_set( - "light.filtered", STATE_ON, {"brightness": 100, ATTR_UNIT_OF_MEASUREMENT: "any"} + "sensor.filtered", + STATE_ON, + {"brightness": 100, ATTR_UNIT_OF_MEASUREMENT: "any"}, ) await hass.async_block_till_done() hass.states.async_set("light.kitchen", STATE_OFF, {"brightness": 200}) await hass.async_block_till_done() hass.states.async_set( - "light.filtered", + "sensor.filtered", STATE_OFF, {"brightness": 300, ATTR_UNIT_OF_MEASUREMENT: "any"}, ) @@ -345,7 +349,7 @@ async def test_get_events_entities_filtered_away( "id": 2, "type": "logbook/get_events", "start_time": now.isoformat(), - "entity_ids": ["light.filtered"], + "entity_ids": ["sensor.filtered"], } ) response = await client.receive_json() @@ -2273,11 +2277,11 @@ async def test_live_stream_with_one_second_commit_interval( hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "5"}) - recieved_rows = [] + received_rows = [] msg = await asyncio.wait_for(websocket_client.receive_json(), 2) assert msg["id"] == 7 assert msg["type"] == "event" - recieved_rows.extend(msg["event"]["events"]) + received_rows.extend(msg["event"]["events"]) hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "6"}) @@ -2285,14 +2289,14 @@ async def test_live_stream_with_one_second_commit_interval( hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "7"}) - while len(recieved_rows) < 7: + while len(received_rows) < 7: msg = await asyncio.wait_for(websocket_client.receive_json(), 2.5) assert msg["id"] == 7 assert msg["type"] == "event" - recieved_rows.extend(msg["event"]["events"]) + received_rows.extend(msg["event"]["events"]) # Make sure we get rows back in order - assert recieved_rows == [ + assert received_rows == [ {"domain": "test", "message": "1", "name": "device name", "when": ANY}, {"domain": "test", "message": "2", "name": "device name", "when": ANY}, {"domain": "test", "message": "3", "name": "device name", "when": ANY}, @@ -3014,15 +3018,15 @@ async def test_live_stream_with_changed_state_change( await hass.async_block_till_done() hass.states.async_set("binary_sensor.is_light", STATE_ON) - recieved_rows = [] - while len(recieved_rows) < 3: + received_rows = [] + while len(received_rows) < 3: msg = await asyncio.wait_for(websocket_client.receive_json(), 2.5) assert msg["id"] == 7 assert msg["type"] == "event" - recieved_rows.extend(msg["event"]["events"]) + received_rows.extend(msg["event"]["events"]) # Make sure we get rows back in order - assert recieved_rows == [ + assert received_rows == [ {"entity_id": "binary_sensor.is_light", "state": "unknown", "when": ANY}, {"entity_id": "binary_sensor.is_light", "state": "on", "when": ANY}, {"entity_id": "binary_sensor.is_light", "state": "off", "when": ANY}, @@ -3041,3 +3045,160 @@ async def test_live_stream_with_changed_state_change( assert listeners_without_writes( hass.bus.async_listeners() ) == listeners_without_writes(init_listeners) + + +@pytest.mark.parametrize( + ("entity_id", "attributes", "result_count"), + [ + ( + "light.kitchen", + {ATTR_UNIT_OF_MEASUREMENT: "any", "brightness": 100}, + 1, # Light is not a filterable domain + ), + ( + "sensor.sensor0", + {ATTR_UNIT_OF_MEASUREMENT: "any"}, + 0, # Sensor with UoM is always filtered + ), + ( + "sensor.sensor1", + {ATTR_DEVICE_CLASS: SensorDeviceClass.AQI}, + 0, # Sensor with a numeric device class is always filtered + ), + ( + "sensor.sensor2", + {ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM}, + 1, # Sensor with a non-numeric device class is not filtered + ), + ( + "sensor.sensor3", + {ATTR_STATE_CLASS: "any"}, + 0, # Sensor with state class is always filtered + ), + ( + "sensor.sensor4", + {}, + 1, # Sensor with no UoM, device_class, or state_class is not filtered + ), + ( + "number.number0", + {ATTR_UNIT_OF_MEASUREMENT: "any"}, + 1, # Non-sensor domains are not filtered by presence of UoM + ), + ( + "number.number1", + {}, + 1, # Not a filtered domain + ), + ( + "input_number.number0", + {ATTR_UNIT_OF_MEASUREMENT: "any"}, + 1, # Non-sensor domains are not filtered by presence of UoM + ), + ( + "input_number.number1", + {}, + 1, # Not a filtered domain + ), + ( + "counter.counter0", + {}, + 0, # Counter is an always continuous domain + ), + ( + "zone.home", + {}, + 1, # Zone is not an always continuous domain + ), + ], +) +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_consistent_stream_and_recorder_filtering( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_id: str, + attributes: dict, + result_count: int, +) -> None: + """Test that the logbook live stream and get_events apis use consistent filtering rules.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + await async_recorder_block_till_done(hass) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + hass.states.async_set(entity_id, "1.0", attributes) + hass.states.async_set("binary_sensor.other_entity", "off") + + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 1, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": [entity_id, "binary_sensor.other_entity"], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 1 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" in msg["event"] + await async_wait_recording_done(hass) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 1 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" not in msg["event"] + await async_wait_recording_done(hass) + + hass.states.async_set( + entity_id, + "2.0", + attributes, + ) + hass.states.async_set("binary_sensor.other_entity", "on") + await get_instance(hass).async_block_till_done() + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 1 + assert msg["type"] == "event" + assert "partial" not in msg["event"] + assert len(msg["event"]["events"]) == 1 + result_count + + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + await websocket_client.send_json( + { + "id": 2, + "type": "logbook/get_events", + "start_time": now.isoformat(), + "entity_ids": [entity_id], + } + ) + response = await websocket_client.receive_json() + assert response["success"] + assert response["id"] == 2 + + results = response["result"] + assert len(results) == result_count diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py new file mode 100644 index 00000000000..bc9e44d2e09 --- /dev/null +++ b/tests/components/lunatone/__init__.py @@ -0,0 +1,76 @@ +"""Tests for the Lunatone integration.""" + +from typing import Final + +from lunatone_rest_api_client.models import ( + DeviceData, + DeviceInfoData, + DevicesData, + FeaturesStatus, + InfoData, +) +from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status +from lunatone_rest_api_client.models.devices import DeviceStatus + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +BASE_URL: Final = "http://10.0.0.131" +SERIAL_NUMBER: Final = 12345 +VERSION: Final = "v1.14.1/1.4.3" + +DEVICE_DATA_LIST: Final[list[DeviceData]] = [ + DeviceData( + id=1, + name="Device 1", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorKelvin=Status[int](status=1000), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)), + ), + address=0, + line=0, + ), + DeviceData( + id=2, + name="Device 2", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorKelvin=Status[int](status=1000), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)), + ), + address=1, + line=0, + ), +] +DEVICES_DATA: Final[DevicesData] = DevicesData(devices=DEVICE_DATA_LIST) +INFO_DATA: Final[InfoData] = InfoData( + name="Test", + version=VERSION, + device=DeviceInfoData( + serial=SERIAL_NUMBER, + gtin=192837465, + pcb="2a", + articleNumber=87654321, + productionYear=20, + productionWeek=1, + ), +) + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Lunatone 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() diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py new file mode 100644 index 00000000000..5f60d084788 --- /dev/null +++ b/tests/components/lunatone/conftest.py @@ -0,0 +1,82 @@ +"""Fixtures for Lunatone tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, PropertyMock, patch + +from lunatone_rest_api_client import Device, Devices +import pytest + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.const import CONF_URL + +from . import BASE_URL, DEVICES_DATA, INFO_DATA, SERIAL_NUMBER + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lunatone.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_lunatone_devices() -> Generator[AsyncMock]: + """Mock a Lunatone devices object.""" + + def build_devices_mock(devices: Devices): + device_list = [] + for device_data in devices.data.devices: + device = AsyncMock(spec=Device) + device.data = device_data + device.id = device.data.id + device.name = device.data.name + device.is_on = device.data.features.switchable.status + device_list.append(device) + return device_list + + with patch( + "homeassistant.components.lunatone.Devices", autospec=True + ) as mock_devices: + devices = mock_devices.return_value + devices.data = DEVICES_DATA + type(devices).devices = PropertyMock( + side_effect=lambda d=devices: build_devices_mock(d) + ) + yield devices + + +@pytest.fixture +def mock_lunatone_info() -> Generator[AsyncMock]: + """Mock a Lunatone info object.""" + with ( + patch( + "homeassistant.components.lunatone.Info", + autospec=True, + ) as mock_info, + patch( + "homeassistant.components.lunatone.config_flow.Info", + new=mock_info, + ), + ): + info = mock_info.return_value + info.data = INFO_DATA + info.name = info.data.name + info.version = info.data.version + info.serial_number = info.data.device.serial + yield info + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=f"Lunatone {SERIAL_NUMBER}", + domain=DOMAIN, + data={CONF_URL: BASE_URL}, + unique_id=str(SERIAL_NUMBER), + ) diff --git a/tests/components/lunatone/snapshots/test_light.ambr b/tests/components/lunatone/snapshots/test_light.ambr new file mode 100644 index 00000000000..b2762be4540 --- /dev/null +++ b/tests/components/lunatone/snapshots/test_light.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_setup[light.device_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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.device_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': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345-device1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Device 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.device_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[light.device_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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.device_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': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345-device2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Device 2', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.device_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py new file mode 100644 index 00000000000..56bae075a19 --- /dev/null +++ b/tests/components/lunatone/test_config_flow.py @@ -0,0 +1,184 @@ +"""Define tests for the Lunatone config flow.""" + +from unittest.mock import AsyncMock + +import aiohttp +import pytest + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import BASE_URL, SERIAL_NUMBER + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_lunatone_info: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test full user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"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"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Test {SERIAL_NUMBER}" + assert result["data"] == {CONF_URL: BASE_URL} + + +async def test_full_flow_fail_because_of_missing_device_infos( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, +) -> None: + """Test full flow.""" + mock_lunatone_info.data = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "missing_device_info"} + + +async def test_device_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that the flow is aborted when the device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: BASE_URL}, + ) + + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"), + (aiohttp.ClientConnectionError(), "cannot_connect"), + ], +) +async def test_user_step_fail_with_error( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test user step with an error.""" + mock_lunatone_info.async_update.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"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"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_lunatone_info.async_update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Test {SERIAL_NUMBER}" + assert result["data"] == {CONF_URL: BASE_URL} + + +async def test_reconfigure( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + url = "http://10.0.0.100" + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_URL: url} + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"), + (aiohttp.ClientConnectionError(), "cannot_connect"), + ], +) +async def test_reconfigure_fail_with_error( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure flow with an error.""" + url = "http://10.0.0.100" + + mock_lunatone_info.async_update.side_effect = exception + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_lunatone_info.async_update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_URL: url} diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py new file mode 100644 index 00000000000..0e063b25adb --- /dev/null +++ b/tests/components/lunatone/test_init.py @@ -0,0 +1,133 @@ +"""Tests for the Lunatone integration.""" + +from unittest.mock import AsyncMock + +import aiohttp + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import BASE_URL, VERSION, setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the Lunatone configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.manufacturer == "Lunatone" + assert device_entry.sw_version == VERSION + assert device_entry.configuration_url == BASE_URL + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready_info_api_fail( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a failure in the info API.""" + mock_lunatone_info.async_update.side_effect = aiohttp.ClientConnectionError() + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_lunatone_info.async_update.side_effect = None + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_lunatone_info.async_update.assert_called() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready_devices_api_fail( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a failure in the devices API.""" + mock_lunatone_devices.async_update.side_effect = aiohttp.ClientConnectionError() + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + mock_lunatone_devices.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_lunatone_devices.async_update.side_effect = None + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_lunatone_info.async_update.assert_called() + mock_lunatone_devices.async_update.assert_called() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready_no_info_data( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to missing info data.""" + mock_lunatone_info.data = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_no_devices_data( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to missing devices data.""" + mock_lunatone_devices.data = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + mock_lunatone_devices.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_no_serial_number( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a missing serial number.""" + mock_lunatone_info.serial_number = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py new file mode 100644 index 00000000000..64262ad497b --- /dev/null +++ b/tests/components/lunatone/test_light.py @@ -0,0 +1,79 @@ +"""Tests for the Lunatone integration.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_ENTITY_ID = "light.device_1" + + +async def test_setup( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Lunatone configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + entities = hass.states.async_all(Platform.LIGHT) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def test_turn_on_off( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the light can be turned on and off.""" + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[0] + device.features.switchable.status = not device.features.switchable.status + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index 5f146cd988a..03b78b1e44e 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -100,6 +100,7 @@ class MockBridge: self.scenes = self.get_scenes() self.devices = self.load_devices() self.buttons = self.load_buttons() + self._subscribers: dict[str, list] = {} async def connect(self): """Connect the mock bridge.""" @@ -110,10 +111,23 @@ class MockBridge: def add_subscriber(self, device_id: str, callback_): """Mock a listener to be notified of state changes.""" + if device_id not in self._subscribers: + self._subscribers[device_id] = [] + self._subscribers[device_id].append(callback_) def add_button_subscriber(self, button_id: str, callback_): """Mock a listener for button presses.""" + def call_subscribers(self, device_id: str): + """Notify subscribers of a device state change.""" + if device_id in self._subscribers: + for callback in self._subscribers[device_id]: + callback() + + def get_device_by_id(self, device_id: str): + """Get a device by its ID.""" + return self.devices.get(device_id) + def is_connected(self): """Return whether the mock bridge is connected.""" return self.is_currently_connected @@ -309,6 +323,20 @@ class MockBridge: def tap_button(self, button_id: str): """Mock a button press and release message for the given button ID.""" + async def set_value(self, device_id: str, value: int) -> None: + """Mock setting a device value.""" + if device_id in self.devices: + self.devices[device_id]["current_state"] = value + + async def raise_cover(self, device_id: str) -> None: + """Mock raising a cover.""" + + async def lower_cover(self, device_id: str) -> None: + """Mock lowering a cover.""" + + async def stop_cover(self, device_id: str) -> None: + """Mock stopping a cover.""" + async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False diff --git a/tests/components/lutron_caseta/test_cover.py b/tests/components/lutron_caseta/test_cover.py index 5d45f185aef..43c7d986d1b 100644 --- a/tests/components/lutron_caseta/test_cover.py +++ b/tests/components/lutron_caseta/test_cover.py @@ -1,18 +1,303 @@ """Tests for the Lutron Caseta integration.""" +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration +@pytest.fixture +async def mock_bridge_with_cover_mocks(hass: HomeAssistant) -> MockBridge: + """Set up mock bridge with all cover methods mocked for testing.""" + instance = MockBridge() + + def factory(*args: Any, **kwargs: Any) -> MockBridge: + """Return the mock bridge instance.""" + return instance + + # Patch all cover methods on the instance with AsyncMocks + instance.set_value = AsyncMock() + instance.raise_cover = AsyncMock() + instance.lower_cover = AsyncMock() + instance.stop_cover = AsyncMock() + + await async_setup_integration(hass, factory) + await hass.async_block_till_done() + + return instance + + async def test_cover_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test a light unique id.""" + """Test a cover unique ID.""" await async_setup_integration(hass, MockBridge) cover_entity_id = "cover.basement_bedroom_left_shade" # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(cover_entity_id).unique_id == "000004d2_802" + + +async def test_cover_open_close_using_set_value( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that open/close commands use set_value to avoid stuttering.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test opening the cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should use set_value(100) instead of raise_cover + mock_instance.set_value.assert_called_with("802", 100) + mock_instance.raise_cover.assert_not_called() + + mock_instance.set_value.reset_mock() + mock_instance.lower_cover.reset_mock() + + # Test closing the cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should use set_value(0) instead of lower_cover + mock_instance.set_value.assert_called_with("802", 0) + mock_instance.lower_cover.assert_not_called() + + +async def test_cover_stop_with_direction_tracking( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that stop command sends appropriate directional command first.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Simulate shade moving up (opening) + mock_instance.devices["802"]["current_state"] = 30 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + mock_instance.devices["802"]["current_state"] = 60 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop while opening + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should send raise_cover before stop_cover when opening + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + mock_instance.lower_cover.assert_not_called() + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Simulate shade moving down (closing) + mock_instance.devices["802"]["current_state"] = 40 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + mock_instance.devices["802"]["current_state"] = 20 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop while closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should send lower_cover before stop_cover when closing + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + mock_instance.raise_cover.assert_not_called() + + +async def test_cover_stop_at_endpoints( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command behavior when shade is at fully open or closed.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test stop at fully open (100) - should infer it was opening + mock_instance.devices["802"]["current_state"] = 100 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # At fully open, should send raise_cover before stop + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Test stop at fully closed (0) - should infer it was closing + mock_instance.devices["802"]["current_state"] = 0 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # At fully closed, should send lower_cover before stop + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_position_heuristic_fallback( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command uses position heuristic when movement direction is unknown.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test stop at position < 50 with no movement + # Update the device data directly in the bridge's devices dict + mock_instance.devices["802"]["current_state"] = 30 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position < 50, should send lower_cover + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Test stop at position >= 50 with no movement + mock_instance.devices["802"]["current_state"] = 70 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position >= 50, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_stopped_movement_detection( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that movement direction is set to STOPPED when position doesn't change.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Set initial position + mock_instance.devices["802"]["current_state"] = 50 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Send same position again - should detect as stopped + mock_instance.devices["802"]["current_state"] = 50 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop command should use position heuristic (>= 50) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position >= 50 with STOPPED direction, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_startup_with_shade_in_motion( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command when HA starts with shade already in motion.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Shade starts at position 50 (simulating HA startup with shade in motion) + # First stop without seeing movement should use position heuristic + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should have used position heuristic since we haven't seen movement yet + # Initial position is 100 from MockBridge, so >= 50, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Now simulate shade moving down (shade was actually in motion) + mock_instance.devices["802"]["current_state"] = 45 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now we've detected downward movement + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should now correctly send lower_cover since we detected downward movement + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 001bf86ad54..061cfca096a 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -148,6 +148,17 @@ async def test_get_triggers(hass: HomeAssistant) -> None: } for subtype in ("on", "stop", "off", "raise", "lower") ] + expected_triggers += [ + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_SUBTYPE: subtype, + CONF_TYPE: "multi_tap", + "metadata": {}, + } + for subtype in ("on", "stop", "off", "raise", "lower") + ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_id @@ -439,7 +450,7 @@ async def test_validate_trigger_invalid_triggers( }, ) - assert "value must be one of ['press', 'release']" in caplog.text + assert "value must be one of ['multi_tap', 'press', 'release']" in caplog.text async def test_if_fires_on_button_event_late_setup( diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index d8979083de9..0a0e203bf28 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -32,12 +32,16 @@ def mock_mastodon_client() -> Generator[AsyncMock]: ) as mock_client, ): client = mock_client.return_value - client.instance.return_value = InstanceV2.from_json( + client.instance_v1.return_value = InstanceV2.from_json( + load_fixture("instance.json", DOMAIN) + ) + client.instance_v2.return_value = InstanceV2.from_json( load_fixture("instance.json", DOMAIN) ) client.account_verify_credentials.return_value = Account.from_json( load_fixture("account_verify_credentials.json", DOMAIN) ) + client.mastodon_api_version = 2 client.status_post.return_value = None yield client diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index ec9da1836bc..81abc77e21f 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -83,3 +83,87 @@ }), }) # --- +# name: test_entry_diagnostics_fallback_to_instance_v1 + dict({ + 'account': dict({ + 'acct': 'trwnh', + 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'bot': True, + 'created_at': '2016-11-24T00:00:00+00:00', + 'discoverable': True, + 'display_name': 'infinite love ⴳ', + 'emojis': list([ + ]), + 'fields': list([ + dict({ + 'name': 'Website', + 'value': 'trwnh.com', + 'verified_at': '2019-08-29T04:14:55.571+00:00', + }), + dict({ + 'name': 'Portfolio', + 'value': 'abdullahtarawneh.com', + 'verified_at': '2021-02-11T20:34:13.574+00:00', + }), + dict({ + 'name': 'Fan of:', + 'value': 'Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)', + 'verified_at': None, + }), + dict({ + 'name': 'What to expect:', + 'value': 'talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.', + 'verified_at': None, + }), + ]), + 'followers_count': 3169, + 'following_count': 328, + 'group': False, + 'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'hide_collections': True, + 'id': '14715', + 'indexable': False, + 'last_status_at': '2025-03-04T00:00:00', + 'limited': None, + 'locked': False, + 'memorial': None, + 'moved': None, + 'moved_to_account': None, + 'mute_expires_at': None, + 'noindex': False, + 'note': '

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
trwnh.com
help me live:
- donate.stripe.com/4gwcPCaMpcQ1
- liberapay.com/trwnh

notes:
- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence

', + 'role': None, + 'roles': list([ + ]), + 'source': None, + 'statuses_count': 69523, + 'suspended': None, + 'uri': 'https://mastodon.social/users/trwnh', + 'url': 'https://mastodon.social/@trwnh', + 'username': 'trwnh', + }), + 'instance': dict({ + 'api_versions': None, + 'configuration': None, + 'contact': None, + 'description': 'The original server operated by the Mastodon gGmbH non-profit', + 'domain': 'mastodon.social', + 'icon': None, + 'languages': None, + 'registrations': None, + 'rules': None, + 'source_url': 'https://github.com/mastodon/mastodon', + 'thumbnail': None, + 'title': 'Mastodon', + 'uri': 'mastodon.social', + 'usage': dict({ + 'users': dict({ + 'active_month': 380143, + }), + }), + 'version': '4.4.0-nightly.2025-02-07', + }), + }) +# --- diff --git a/tests/components/mastodon/test_config_flow.py b/tests/components/mastodon/test_config_flow.py index 4b022df2ca2..5f1014c31d3 100644 --- a/tests/components/mastodon/test_config_flow.py +++ b/tests/components/mastodon/test_config_flow.py @@ -2,7 +2,11 @@ from unittest.mock import AsyncMock -from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError +from mastodon.Mastodon import ( + MastodonNetworkError, + MastodonNotFoundError, + MastodonUnauthorizedError, +) import pytest from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN @@ -80,6 +84,46 @@ async def test_full_flow_with_path( assert result["result"].unique_id == "trwnh_mastodon_social" +async def test_full_flow_fallback_to_instance_v1( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow where instance_v2 fails and falls back to instance_v1.""" + mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError( + "Instance API v2 not found" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"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"], + { + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "@trwnh@mastodon.social" + assert result["data"] == { + CONF_BASE_URL: "https://mastodon.social", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + } + assert result["result"].unique_id == "trwnh_mastodon_social" + + mock_mastodon_client.instance_v2.assert_called_once() + mock_mastodon_client.instance_v1.assert_called_once() + + @pytest.mark.parametrize( ("exception", "error"), [ diff --git a/tests/components/mastodon/test_diagnostics.py b/tests/components/mastodon/test_diagnostics.py index 531543ee65d..a3ee1b8eea3 100644 --- a/tests/components/mastodon/test_diagnostics.py +++ b/tests/components/mastodon/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from mastodon.Mastodon import MastodonNotFoundError from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -26,3 +27,26 @@ async def test_entry_diagnostics( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) == snapshot ) + + +async def test_entry_diagnostics_fallback_to_instance_v1( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics with fallback to instance_v1 when instance_v2 raises MastodonNotFoundError.""" + mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError( + "Instance v2 not found" + ) + + await setup_integration(hass, mock_config_entry) + + diagnostics_result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + mock_mastodon_client.instance_v1.assert_called() + + assert diagnostics_result == snapshot diff --git a/tests/components/mastodon/test_init.py b/tests/components/mastodon/test_init.py index c3d0728fe08..b4808792f66 100644 --- a/tests/components/mastodon/test_init.py +++ b/tests/components/mastodon/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from mastodon.Mastodon import MastodonError +from mastodon.Mastodon import MastodonNotFoundError from syrupy.assertion import SnapshotAssertion from homeassistant.components.mastodon.config_flow import MastodonConfigFlow @@ -39,13 +39,30 @@ async def test_initialization_failure( mock_config_entry: MockConfigEntry, ) -> None: """Test initialization failure.""" - mock_mastodon_client.instance.side_effect = MastodonError + mock_mastodon_client.instance_v1.side_effect = MastodonNotFoundError + mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_integration_fallback_to_instance_v1( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test full flow where instance_v2 fails and falls back to instance_v1.""" + mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError( + "Instance API v2 not found" + ) + + await setup_integration(hass, mock_config_entry) + + mock_mastodon_client.instance_v2.assert_called_once() + mock_mastodon_client.instance_v1.assert_called_once() + + async def test_migrate( hass: HomeAssistant, mock_mastodon_client: AsyncMock, diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index b08f886422f..7902db010ca 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.mastodon.const import ( ATTR_CONTENT_WARNING, + ATTR_LANGUAGE, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, ATTR_STATUS, @@ -34,6 +35,7 @@ from tests.common import MockConfigEntry "status": "test toot", "spoiler_text": None, "visibility": None, + "language": None, "media_ids": None, "sensitive": None, }, @@ -44,6 +46,7 @@ from tests.common import MockConfigEntry "status": "test toot", "spoiler_text": None, "visibility": "private", + "language": None, "media_ids": None, "sensitive": None, }, @@ -58,6 +61,7 @@ from tests.common import MockConfigEntry "status": "test toot", "spoiler_text": "Spoiler", "visibility": "private", + "language": None, "media_ids": None, "sensitive": None, }, @@ -66,12 +70,14 @@ from tests.common import MockConfigEntry { ATTR_STATUS: "test toot", ATTR_CONTENT_WARNING: "Spoiler", + ATTR_LANGUAGE: "nl", ATTR_MEDIA: "/image.jpg", }, { "status": "test toot", "spoiler_text": "Spoiler", "visibility": None, + "language": "nl", "media_ids": "1", "sensitive": None, }, @@ -80,6 +86,7 @@ from tests.common import MockConfigEntry { ATTR_STATUS: "test toot", ATTR_CONTENT_WARNING: "Spoiler", + ATTR_LANGUAGE: "en", ATTR_MEDIA: "/image.jpg", ATTR_MEDIA_DESCRIPTION: "A test image", }, @@ -87,10 +94,22 @@ from tests.common import MockConfigEntry "status": "test toot", "spoiler_text": "Spoiler", "visibility": None, + "language": "en", "media_ids": "1", "sensitive": None, }, ), + ( + {ATTR_STATUS: "test toot", ATTR_LANGUAGE: "invalid-lang"}, + { + "status": "test toot", + "language": "invalid-lang", + "spoiler_text": None, + "visibility": None, + "media_ids": None, + "sensitive": None, + }, + ), ], ) async def test_service_post( diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 5895c3472d6..9b82f2ac305 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -76,6 +76,8 @@ async def integration_fixture( params=[ "air_purifier", "air_quality_sensor", + "aqara_door_window_p2", + "aqara_motion_p2", "battery_storage", "color_temperature_light", "cooktop", @@ -119,6 +121,7 @@ async def integration_fixture( "smoke_detector", "solar_power", "switch_unit", + "tado_smart_radiator_thermostat_x", "temperature_sensor", "thermostat", "vacuum_cleaner", diff --git a/tests/components/matter/fixtures/nodes/aqara_door_window_p2.json b/tests/components/matter/fixtures/nodes/aqara_door_window_p2.json new file mode 100644 index 00000000000..31da2f73135 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/aqara_door_window_p2.json @@ -0,0 +1,274 @@ +{ + "node_id": 91, + "date_commissioned": "2025-08-27T14:23:11.565546", + "last_interview": "2025-08-27T14:23:11.565564", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 60, 62, 63, 70], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Aqara", + "0/40/2": 4447, + "0/40/3": "Aqara Door and Window Sensor P2", + "0/40/4": 8194, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1000, + "0/40/8": "1.0.0.0", + "0/40/9": 1020, + "0/40/10": "1.0.2.0", + "0/40/11": "20240307", + "0/40/12": "AS056", + "0/40/13": "https://www.aqara.com/en/products.html", + "0/40/14": "Aqara Door and Window Sensor P2", + "0/40/15": "18C23C301AF1", + "0/40/16": false, + "0/40/18": "77C345BEF0788EAA", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039616, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/9": 4, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "TpjVA8V9JuQ=", + "5": [], + "6": [ + "/QANuACgAAAAAAD//gB0BQ==", + "/akBUIsgAADKCE1ClpBSFg==", + "/QANuACgAAABbF+WmHF+eQ==", + "/oAAAAAAAABMmNUDxX0m5A==" + ], + "7": 4 + } + ], + "0/51/1": 1, + "0/51/2": 243908, + "0/51/4": 5, + "0/51/5": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 4, 5, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRWxgkBwEkABEwCUEE+peDgNJHKLjgJvLbLi2P19VuzdosAzWAoYTo4tXewHOLMbRnatNlOYBB6F9h5CMq4nPrRWBqypU3EtRioKp9SDcKNQEoARgkAgE2AwQCBAEYMAQUiCfvxd9ZpmZGiRYA623GNkFOjOkwBRRT9HTfU5Nds+HA8j+/MRP+0pVyIxgwC0B/mNC2wE79uQXrOQYNNYjDzo34FgewXvHAwAameZ6HnxEbliDkdgN1XdbJdD0eAZzaL/x7u2SDCV7+xutHj4kzGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyT62Yt4qMI+MorlmQ/Hxh2CpLetznVknlAbhvYAwTexpSxp9GnhR09SrcUhz3mOb0eZa2TylqcnPBhHJ2Ih2RTcKNQEpARgkAmAwBBRT9HTfU5Nds+HA8j+/MRP+0pVyIzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQI/Kc38hQyK7AkT7/pN4hiYW3LoWKT3NA43+ssMJoVpDcaZ989GXBQKIbHKbBEXzUQ1J8wfL7l2pL0Z8Lso9JwgY", + "254": 5 + } + ], + "0/62/1": [ + { + "1": "BIrruNo7r0gX6j6lq1dDi5zeK3jxcTavjt2o4adCCSCYtbxOakfb7C3GXqgV4LzulFSinbewmYkdqFBHqm5pxvU=", + "2": 4939, + "3": 2, + "4": 91, + "5": "", + "254": 5 + } + ], + "0/62/2": 5, + "0/62/3": 5, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBOlVCjAkBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQOBWUeARjjwVS/2MgJEXQhGDcLOZZWhH/hrGZmuRPmmQI1uezrxB5DnsUJXElXlVukcwXEYIeQg8nenm18jU6w4Y", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y", + "FTABD38O1NiPyscyxScZaN7uECQCATcDJhSoQfl2GCYEIqqfLyYFImy36zcGJhSoQfl2GCQHASQIATAJQQT5WrI2v6EgLRXdxlmZLlXX3rxeBe1C3NN/x9QV0tMVF+gH/FPSyq69dZKuoyskx0UOHcN20wdPffFuqgy/4uiaNwo1ASkBGCQCYDAEFM8XoLF/WKnSeqflSO5TQBQz4ObIMAUUzxegsX9YqdJ6p+VI7lNAFDPg5sgYMAtAHTWpsQPPwqR9gCqBGcDbPu2gusKeVuytcD5v7qK1/UjVr2/WGjMw3SYM10HWKdPTQZa2f3JI3uxv1nFnlcQpDBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY" + ], + "0/62/5": 5, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/70/0": 600, + "0/70/1": 10000, + "0/70/2": 5000, + "0/70/65532": 0, + "0/70/65533": 3, + "0/70/65528": [], + "0/70/65529": [], + "0/70/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 21, + "1": 1 + } + ], + "1/29/1": [3, 29, 69, 128], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/69/0": true, + "1/69/65532": 0, + "1/69/65533": 1, + "1/69/65528": [], + "1/69/65529": [], + "1/69/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/128/0": 2, + "1/128/1": 3, + "1/128/2": 1, + "1/128/65532": 8, + "1/128/65533": 1, + "1/128/65528": [], + "1/128/65529": [], + "1/128/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 17, + "1": 1 + } + ], + "2/29/1": [29, 47], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/47/0": 1, + "2/47/1": 0, + "2/47/2": "Battery", + "2/47/11": 3010, + "2/47/12": 200, + "2/47/14": 0, + "2/47/15": false, + "2/47/16": 2, + "2/47/19": "CR123A", + "2/47/25": 1, + "2/47/31": [], + "2/47/65532": 10, + "2/47/65533": 2, + "2/47/65528": [], + "2/47/65529": [], + "2/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 19, 25, 31, 65528, 65529, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/aqara_motion_p2.json b/tests/components/matter/fixtures/nodes/aqara_motion_p2.json new file mode 100644 index 00000000000..96dc7c76821 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/aqara_motion_p2.json @@ -0,0 +1,309 @@ +{ + "node_id": 83, + "date_commissioned": "2025-08-09T16:22:00.289575", + "last_interview": "2025-08-25T07:17:44.834405", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 60, 62, 63, 70], + "0/29/2": [41], + "0/29/3": [1, 2, 3], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Aqara", + "0/40/2": 4447, + "0/40/3": "Aqara Motion and Light Sensor P2", + "0/40/4": 8195, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1000, + "0/40/8": "1.0.0.0", + "0/40/9": 1031, + "0/40/10": "1.0.3.1", + "0/40/11": "20240201", + "0/40/12": "AS057", + "0/40/13": "https://www.aqara.com/en/products.html", + "0/40/14": "Aqara Motion and Light Sensor P2", + "0/40/15": "18C23C2F3F08", + "0/40/16": false, + "0/40/18": "47937B9DA39E1189", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039616, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/9": 4, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "YmXZYp+mnAg=", + "5": [], + "6": [ + "/QANuACgAAAAAAD//gBgAQ==", + "/U8h7+VkAAByQ/pRGsN9gg==", + "/QANuACgBEACp5kHYJuFWzA==", + "/oAAAAAAAABgZdlin6acCA==" + ], + "7": 4 + } + ], + "0/51/1": 1, + "0/51/2": 14088, + "0/51/4": 5, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 4, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRUxgkBwEkCAEwCUEEavB0X/e5zUwbpO9fZAN2FO4U+PXk6rhtGmcKcTHz6GQ0pbzcEwae2oDap6Ya9/UdMR5sgu5+DmFO3lY/lNcpKzcKNQEoARgkAgE2AwQCBAEYMAQUIMN8SBj14ODdZCWLAXSM/Xb/OjcwBRRT9HTfU5Nds+HA8j+/MRP+0pVyIxgwC0ADqNySYm7AmxGHUxuOGbNSX8urRmrcYbCKtJw5ENik9cVDYXcrcr42/h92NWdnArOvJ5pyzdC0d4hd0aMg9jeYGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyT62Yt4qMI+MorlmQ/Hxh2CpLetznVknlAbhvYAwTexpSxp9GnhR09SrcUhz3mOb0eZa2TylqcnPBhHJ2Ih2RTcKNQEpARgkAmAwBBRT9HTfU5Nds+HA8j+/MRP+0pVyIzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQI/Kc38hQyK7AkT7/pN4hiYW3LoWKT3NA43+ssMJoVpDcaZ989GXBQKIbHKbBEXzUQ1J8wfL7l2pL0Z8Lso9JwgY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BIrruNo7r0gX6j6lq1dDi5zeK3jxcTavjt2o4adCCSCYtbxOakfb7C3GXqgV4LzulFSinbewmYkdqFBHqm5pxvU=", + "2": 4939, + "3": 2, + "4": 83, + "5": "Maison", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBPQwKjAkBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQHV8h9QygCRCcooCFzuAoznwLq0s1JeUBFPTU6JiGqF15OFnFDOkkDE6NA9Km2J8bn35913QhJ5FKWB6Tz/5jfYY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY", + "FTABD38O1NiPyscyxScZaN7uECQCATcDJhSoQfl2GCYEIqqfLyYFImy36zcGJhSoQfl2GCQHASQIATAJQQT5WrI2v6EgLRXdxlmZLlXX3rxeBe1C3NN/x9QV0tMVF+gH/FPSyq69dZKuoyskx0UOHcN20wdPffFuqgy/4uiaNwo1ASkBGCQCYDAEFM8XoLF/WKnSeqflSO5TQBQz4ObIMAUUzxegsX9YqdJ6p+VI7lNAFDPg5sgYMAtAHTWpsQPPwqR9gCqBGcDbPu2gusKeVuytcD5v7qK1/UjVr2/WGjMw3SYM10HWKdPTQZa2f3JI3uxv1nFnlcQpDBg=" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/70/0": 600, + "0/70/1": 10000, + "0/70/2": 5000, + "0/70/65532": 0, + "0/70/65533": 3, + "0/70/65528": [], + "0/70/65529": [], + "0/70/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 263, + "1": 1 + } + ], + "1/29/1": [3, 29, 128, 1030], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/128/0": 1, + "1/128/1": 3, + "1/128/2": 1, + "1/128/65532": 8, + "1/128/65533": 1, + "1/128/65528": [], + "1/128/65529": [], + "1/128/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1030/0": 0, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/3": 30, + "1/1030/4": { + "0": 5, + "1": 300, + "2": 30 + }, + "1/1030/65532": 2, + "1/1030/65533": 5, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "2/3/0": 0, + "2/3/1": 2, + "2/3/65532": 0, + "2/3/65533": 5, + "2/3/65528": [], + "2/3/65529": [0, 64], + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 262, + "1": 1 + } + ], + "2/29/1": [3, 29, 1024], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/1024/0": 15683, + "2/1024/1": 1, + "2/1024/2": 31761, + "2/1024/65532": 0, + "2/1024/65533": 3, + "2/1024/65528": [], + "2/1024/65529": [], + "2/1024/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "3/29/0": [ + { + "0": 17, + "1": 1 + } + ], + "3/29/1": [29, 47], + "3/29/2": [], + "3/29/3": [], + "3/29/65532": 0, + "3/29/65533": 2, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "3/47/0": 1, + "3/47/1": 0, + "3/47/2": "Battery", + "3/47/11": 2904, + "3/47/12": 100, + "3/47/14": 0, + "3/47/15": false, + "3/47/16": 2, + "3/47/19": "CR2450", + "3/47/25": 2, + "3/47/31": [], + "3/47/65532": 10, + "3/47/65533": 3, + "3/47/65528": [], + "3/47/65529": [], + "3/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 19, 25, 31, 65528, 65529, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/tado_smart_radiator_thermostat_x.json b/tests/components/matter/fixtures/nodes/tado_smart_radiator_thermostat_x.json new file mode 100644 index 00000000000..9111ffd03fe --- /dev/null +++ b/tests/components/matter/fixtures/nodes/tado_smart_radiator_thermostat_x.json @@ -0,0 +1,198 @@ +{ + "node_id": 12, + "date_commissioned": "2024-11-30T14:42:32.255793", + "last_interview": "2025-09-02T11:11:02.931246", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "tado\u00b0 GmbH", + "0/40/2": 4942, + "0/40/3": "Smart Radiator Thermostat X", + "0/40/4": 1, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "VA04", + "0/40/9": 64, + "0/40/10": "1.0", + "0/40/18": "86A085E50D5A98E9", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "DghqP9mExis=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "DghqP9mExis=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "JgVorK4gwNo=", + "5": [], + "6": [ + "/cSCg76PeeDU8k9/8VDoCg==", + "/oAAAAAAAAAkBWisriDA2g==", + "/YyzDI0GAAEI590S93bZ+g==" + ], + "7": 4 + } + ], + "0/51/1": 23, + "0/51/2": 110, + "0/51/3": 6840, + "0/51/4": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 4, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 5, + "0/62/4": [ + "FTABAQAkAgE3AycU3mS65o4n65AmFdZw72wYJgQxwoAuJAUANwYnFN5kuuaOJ+uQJhXWcO9sGCQHASQIATAJQQQdNLSJLh6Ew+9dc42ZSEaQD2i1mavRjPh7ERTyLn8CmfJWgG9s4LZKLdh1Qu5gz5wiKQtzQwLmvjEVyMbO7YwDNwo1ASkBGCQCYDAEFG7exdou0CWA9KDmSWy1OVdhMBKHMAUUbt7F2i7QJYD0oOZJbLU5V2EwEocYMAtAF3IcZnJT290miGeEgwDYwxCO383N3BO+F5ESozS503RetTDlxunlA1cPDTKdyPRksfD14zu5erZ51aPKHxa2Qhg=", + "FTABAQAkAgE3AycUi1H2tJ00+fUkFQEYJgRfkd0uJAUANwYnFItR9rSdNPn1JBUBGCQHASQIATAJQQS9bdXZ/ocAnGmFJBkbm6+buMcdLgy3kQnyiIJ0gPArOweblS5eFfXnRSBWP7QcV7Nd7yiAUNncF+0kMrbpjEX+Nwo1ASkBGCQCYDAEFON8FiGqis2G9n3okV7J/BquBFbUMAUU43wWIaqKzYb2feiRXsn8Gq4EVtQYMAtAVYvBt/DVrSHJdjHZ7Spdtn3amDLOsTNzjsQcBOyESjCH43ZsgKQXmgqSXh+DS4qBNJm0eVo+Vn2gbhOlqubYMBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEUVnmOqdwGAsJNKvBP6t8dNPIV8vb+7vMEdmLTlDtli9YsaJCIhfOAGWRQROt8++O953j/fnjmO6BiAKctAnrxTcKNQEpARgkAmAwBBQrF7Zs6XmGG6lbxviD1v3sViKTrDAFFCsXtmzpeYYbqVvG+IPW/exWIpOsGDALQOe8gq02WhNZYr3kUdGqSKmcl1yFgBY80ebOduJb4lzLWgCq527c8xUZjxx4fFsP9A/K8GqHwQ3mZ2+5/riGunsY", + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABAQAkAgE3AyYU4K5SDiYVI+Px/RgmBFfPHS8kBQA3BiYU4K5SDiYVI+Px/RgkBwEkCAEwCUEE/TWWQD6IXIqrlp/p0JaU1cWtFS88ERh82o2TP6qME9opV5HUntiUCAhRLHnIWtYZ4pubaOWUFoIp61NEP7tuUDcKNQEpARgkAmAwBBQ6xz8FGl9kRhSgC0R+nqgacfJGiDAFFDrHPwUaX2RGFKALRH6eqBpx8kaIGDALQLo8R2G//5ZeXJcE5MQ3YbJ0AJl0Ik97fKD6i/Kx2aGK2oumz3pyAsWd4gVWQxShlFdhoBhv27/HxvP3C9U++k0Y" + ], + "0/62/5": 4, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 4, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 769, + "1": 2 + } + ], + "1/29/1": [3, 29, 513, 1029], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/513/0": 2090, + "1/513/3": 500, + "1/513/4": 3000, + "1/513/18": 1800, + "1/513/27": 2, + "1/513/28": 0, + "1/513/65532": 1, + "1/513/65533": 5, + "1/513/65528": [], + "1/513/65529": [0], + "1/513/65531": [0, 3, 4, 18, 27, 28, 65528, 65529, 65531, 65532, 65533], + "1/1029/0": 7492, + "1/1029/1": 0, + "1/1029/2": 10000, + "1/1029/65532": 0, + "1/1029/65533": 3, + "1/1029/65528": [], + "1/1029/65529": [], + "1/1029/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index a7abff41331..620738d2e7e 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -317,6 +317,8 @@ "1/64/65529": [], "1/64/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/513/0": 2830, + "1/513/1": 1250, + "1/513/2": 1, "1/513/3": null, "1/513/4": null, "1/513/5": null, diff --git a/tests/components/matter/fixtures/nodes/vacuum_cleaner.json b/tests/components/matter/fixtures/nodes/vacuum_cleaner.json index 8f900616799..69f2e9bff86 100644 --- a/tests/components/matter/fixtures/nodes/vacuum_cleaner.json +++ b/tests/components/matter/fixtures/nodes/vacuum_cleaner.json @@ -357,7 +357,7 @@ ], "1/336/2": [], "1/336/3": 7, - "1/336/4": null, + "1/336/4": 1756501200, "1/336/5": [], "1/336/65532": 6, "1/336/65533": 1, diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index 8756efdfbd2..5d1c9b029f9 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -1,4 +1,102 @@ # serializer version: 1 +# name: test_binary_sensors[aqara_door_window_p2][binary_sensor.aqara_door_and_window_sensor_p2_door-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': None, + 'entity_id': 'binary_sensor.aqara_door_and_window_sensor_p2_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-1-ContactSensor-69-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[aqara_door_window_p2][binary_sensor.aqara_door_and_window_sensor_p2_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Aqara Door and Window Sensor P2 Door', + }), + 'context': , + 'entity_id': 'binary_sensor.aqara_door_and_window_sensor_p2_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[aqara_motion_p2][binary_sensor.aqara_motion_and_light_sensor_p2_occupancy-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': None, + 'entity_id': 'binary_sensor.aqara_motion_and_light_sensor_p2_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-OccupancySensor-1030-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[aqara_motion_p2][binary_sensor.aqara_motion_and_light_sensor_p2_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Aqara Motion and Light Sensor P2 Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.aqara_motion_and_light_sensor_p2_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[door_lock][binary_sensor.mock_door_lock_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1124,3 +1222,199 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[thermostat][binary_sensor.longan_link_hvac_occupancy-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': None, + 'entity_id': 'binary_sensor.longan_link_hvac_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOccupancySensor-513-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[thermostat][binary_sensor.longan_link_hvac_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Longan link HVAC Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.longan_link_hvac_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[valve][binary_sensor.valve_general_fault-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.valve_general_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'General fault', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_fault_general_fault', + 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-ValveConfigurationAndControlValveFault_GeneralFault-129-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[valve][binary_sensor.valve_general_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Valve General fault', + }), + 'context': , + 'entity_id': 'binary_sensor.valve_general_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[valve][binary_sensor.valve_valve_blocked-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.valve_valve_blocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve blocked', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_fault_blocked', + 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-ValveConfigurationAndControlValveFault_Blocked-129-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[valve][binary_sensor.valve_valve_blocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Valve Valve blocked', + }), + 'context': , + 'entity_id': 'binary_sensor.valve_valve_blocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[valve][binary_sensor.valve_valve_leaking-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.valve_valve_leaking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve leaking', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_fault_leaking', + 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-ValveConfigurationAndControlValveFault_Leaking-129-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[valve][binary_sensor.valve_valve_leaking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Valve Valve leaking', + }), + 'context': , + 'entity_id': 'binary_sensor.valve_valve_leaking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index f70c38f6b6d..c16f66a5e88 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -95,6 +95,153 @@ 'state': 'unknown', }) # --- +# name: test_buttons[aqara_door_window_p2][button.aqara_door_and_window_sensor_p2_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_door_and_window_sensor_p2_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[aqara_door_window_p2][button.aqara_door_and_window_sensor_p2_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Aqara Door and Window Sensor P2 Identify', + }), + 'context': , + 'entity_id': 'button.aqara_door_and_window_sensor_p2_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[aqara_motion_p2][button.aqara_motion_and_light_sensor_p2_identify_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_motion_and_light_sensor_p2_identify_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[aqara_motion_p2][button.aqara_motion_and_light_sensor_p2_identify_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Aqara Motion and Light Sensor P2 Identify (1)', + }), + 'context': , + 'entity_id': 'button.aqara_motion_and_light_sensor_p2_identify_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[aqara_motion_p2][button.aqara_motion_and_light_sensor_p2_identify_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': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_motion_and_light_sensor_p2_identify_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-2-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[aqara_motion_p2][button.aqara_motion_and_light_sensor_p2_identify_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Aqara Motion and Light Sensor P2 Identify (2)', + }), + 'context': , + 'entity_id': 'button.aqara_motion_and_light_sensor_p2_identify_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[color_temperature_light][button.mock_color_temperature_light_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2135,6 +2282,55 @@ 'state': 'unknown', }) # --- +# name: test_buttons[tado_smart_radiator_thermostat_x][button.smart_radiator_thermostat_x_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.smart_radiator_thermostat_x_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[tado_smart_radiator_thermostat_x][button.smart_radiator_thermostat_x_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Smart Radiator Thermostat X Identify', + }), + 'context': , + 'entity_id': 'button.smart_radiator_thermostat_x_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[temperature_sensor][button.mock_temperature_sensor_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 07a5a69d801..f0745bfe50c 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -199,6 +199,71 @@ 'state': 'off', }) # --- +# name: test_climates[tado_smart_radiator_thermostat_x][climate.smart_radiator_thermostat_x-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.smart_radiator_thermostat_x', + '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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climates[tado_smart_radiator_thermostat_x][climate.smart_radiator_thermostat_x-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 74.92, + 'current_temperature': 20.9, + 'friendly_name': 'Smart Radiator Thermostat X', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.smart_radiator_thermostat_x', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_climates[thermostat][climate.longan_link_hvac-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_light.ambr b/tests/components/matter/snapshots/test_light.ambr index 83b953c9b04..e62c6696c1c 100644 --- a/tests/components/matter/snapshots/test_light.ambr +++ b/tests/components/matter/snapshots/test_light.ambr @@ -281,6 +281,62 @@ 'state': 'on', }) # --- +# name: test_lights[mounted_dimmable_load_control_fixture][light.mock_mounted_dimmable_load_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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.mock_mounted_dimmable_load_control', + '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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterLight-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_lights[mounted_dimmable_load_control_fixture][light.mock_mounted_dimmable_load_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Mounted dimmable load control', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_mounted_dimmable_load_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_lights[multi_endpoint_light][light.inovelli_light_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_lock.ambr b/tests/components/matter/snapshots/test_lock.ambr index 7384449839c..4fbf8ddb822 100644 --- a/tests/components/matter/snapshots/test_lock.ambr +++ b/tests/components/matter/snapshots/test_lock.ambr @@ -37,6 +37,7 @@ # name: test_locks[door_lock][lock.mock_door_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'changed_by': 'Unknown', 'friendly_name': 'Mock Door Lock', 'supported_features': , }), @@ -86,6 +87,7 @@ # name: test_locks[door_lock_with_unbolt][lock.mock_door_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'changed_by': 'Unknown', 'friendly_name': 'Mock Door Lock', 'supported_features': , }), diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 24a92799082..bceec9def46 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -1,4 +1,62 @@ # serializer version: 1 +# name: test_numbers[aqara_motion_p2][number.aqara_motion_and_light_sensor_p2_hold_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65534, + '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.aqara_motion_and_light_sensor_p2_hold_time', + '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': 'Hold time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hold_time', + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-OccupancySensingHoldTime-1030-3', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[aqara_motion_p2][number.aqara_motion_and_light_sensor_p2_hold_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aqara Motion and Light Sensor P2 Hold time', + 'max': 65534, + 'min': 1, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.aqara_motion_and_light_sensor_p2_hold_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- # name: test_numbers[color_temperature_light][number.mock_color_temperature_light_on_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -460,6 +518,121 @@ 'state': '60', }) # --- +# name: test_numbers[door_lock][number.mock_door_lock_user_code_temporary_disable_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time', + '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': 'User code temporary disable time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'user_code_temporary_disable_time', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockUserCodeTemporaryDisableTime-257-49', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[door_lock][number.mock_door_lock_user_code_temporary_disable_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock User code temporary disable time', + 'max': 255, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_numbers[door_lock][number.mock_door_lock_wrong_code_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_door_lock_wrong_code_limit', + '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': 'Wrong code limit', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wrong_code_entry_limit', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockWrongCodeEntryLimit-257-48', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[door_lock][number.mock_door_lock_wrong_code_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Wrong code limit', + 'max': 255, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_door_lock_wrong_code_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_autorelock_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -518,6 +691,121 @@ 'state': '60', }) # --- +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_user_code_temporary_disable_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time', + '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': 'User code temporary disable time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'user_code_temporary_disable_time', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockUserCodeTemporaryDisableTime-257-49', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_user_code_temporary_disable_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock User code temporary disable time', + 'max': 255, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_door_lock_user_code_temporary_disable_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_wrong_code_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_door_lock_wrong_code_limit', + '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': 'Wrong code limit', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wrong_code_entry_limit', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockWrongCodeEntryLimit-257-48', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_wrong_code_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Wrong code limit', + 'max': 255, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_door_lock_wrong_code_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -693,7 +981,7 @@ 'state': '255', }) # --- -# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-entry] +# name: test_numbers[microwave_oven][number.microwave_oven_cooking_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -711,7 +999,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.microwave_oven_cook_time', + 'entity_id': 'number.microwave_oven_cooking_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -723,7 +1011,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Cook time', + 'original_name': 'Cooking time', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -733,11 +1021,11 @@ 'unit_of_measurement': , }) # --- -# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-state] +# name: test_numbers[microwave_oven][number.microwave_oven_cooking_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Microwave Oven Cook time', + 'friendly_name': 'Microwave Oven Cooking time', 'max': 86400, 'min': 1, 'mode': , @@ -745,7 +1033,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.microwave_oven_cook_time', + 'entity_id': 'number.microwave_oven_cooking_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2366,3 +2654,61 @@ 'state': '4.0', }) # --- +# name: test_numbers[valve][number.valve_default_open_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65534, + '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.valve_default_open_duration', + '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': 'Default open duration', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_configuration_and_control_default_open_duration', + 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-ValveConfigurationAndControlDefaultOpenDuration-129-1', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[valve][number.valve_default_open_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve Default open duration', + 'max': 65534, + 'min': 1, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.valve_default_open_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index add827abc5a..aab3d5f7cce 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1,4 +1,63 @@ # serializer version: 1 +# name: test_selects[aqara_door_window_p2][select.aqara_door_and_window_sensor_p2_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '10 mm', + '20 mm', + '30 mm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aqara_door_and_window_sensor_p2_sensitivity', + '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': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-1-AqaraBooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[aqara_door_window_p2][select.aqara_door_and_window_sensor_p2_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aqara Door and Window Sensor P2 Sensitivity', + 'options': list([ + '10 mm', + '20 mm', + '30 mm', + ]), + }), + 'context': , + 'entity_id': 'select.aqara_door_and_window_sensor_p2_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30 mm', + }) +# --- # name: test_selects[color_temperature_light][select.mock_color_temperature_light_lighting-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 290016f0ff3..1f3fc5b0a35 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -353,14 +353,14 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Nitrogen dioxide', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'nitrogen_dioxide', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-NitrogenDioxideSensor-1043-0', 'unit_of_measurement': 'ppm', }) @@ -368,7 +368,6 @@ # name: test_sensors[air_purifier][sensor.air_purifier_nitrogen_dioxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'nitrogen_dioxide', 'friendly_name': 'Air Purifier Nitrogen dioxide', 'state_class': , 'unit_of_measurement': 'ppm', @@ -955,14 +954,14 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Nitrogen dioxide', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'nitrogen_dioxide', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-NitrogenDioxideSensor-1043-0', 'unit_of_measurement': 'ppm', }) @@ -970,7 +969,6 @@ # name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_nitrogen_dioxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'nitrogen_dioxide', 'friendly_name': 'lightfi-aq1-air-quality-sensor Nitrogen dioxide', 'state_class': , 'unit_of_measurement': 'ppm', @@ -1251,6 +1249,379 @@ 'state': '189.0', }) # --- +# name: test_sensors[aqara_door_window_p2][sensor.aqara_door_and_window_sensor_p2_battery-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': , + 'entity_id': 'sensor.aqara_door_and_window_sensor_p2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-2-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[aqara_door_window_p2][sensor.aqara_door_and_window_sensor_p2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Aqara Door and Window Sensor P2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqara_door_and_window_sensor_p2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[aqara_door_window_p2][sensor.aqara_door_and_window_sensor_p2_battery_type-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.aqara_door_and_window_sensor_p2_battery_type', + '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': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-2-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_door_window_p2][sensor.aqara_door_and_window_sensor_p2_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aqara Door and Window Sensor P2 Battery type', + }), + 'context': , + 'entity_id': 'sensor.aqara_door_and_window_sensor_p2_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CR123A', + }) +# --- +# name: test_sensors[aqara_door_window_p2][sensor.aqara_door_and_window_sensor_p2_battery_voltage-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': , + 'entity_id': 'sensor.aqara_door_and_window_sensor_p2_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-2-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[aqara_door_window_p2][sensor.aqara_door_and_window_sensor_p2_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aqara Door and Window Sensor P2 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aqara_door_and_window_sensor_p2_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.01', + }) +# --- +# name: test_sensors[aqara_motion_p2][sensor.aqara_motion_and_light_sensor_p2_battery-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': , + 'entity_id': 'sensor.aqara_motion_and_light_sensor_p2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-3-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[aqara_motion_p2][sensor.aqara_motion_and_light_sensor_p2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Aqara Motion and Light Sensor P2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqara_motion_and_light_sensor_p2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[aqara_motion_p2][sensor.aqara_motion_and_light_sensor_p2_battery_type-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.aqara_motion_and_light_sensor_p2_battery_type', + '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': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-3-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_motion_p2][sensor.aqara_motion_and_light_sensor_p2_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aqara Motion and Light Sensor P2 Battery type', + }), + 'context': , + 'entity_id': 'sensor.aqara_motion_and_light_sensor_p2_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CR2450', + }) +# --- +# name: test_sensors[aqara_motion_p2][sensor.aqara_motion_and_light_sensor_p2_battery_voltage-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': , + 'entity_id': 'sensor.aqara_motion_and_light_sensor_p2_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-3-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[aqara_motion_p2][sensor.aqara_motion_and_light_sensor_p2_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aqara Motion and Light Sensor P2 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aqara_motion_and_light_sensor_p2_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.904', + }) +# --- +# name: test_sensors[aqara_motion_p2][sensor.aqara_motion_and_light_sensor_p2_illuminance-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.aqara_motion_and_light_sensor_p2_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-2-LightSensor-1024-0', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[aqara_motion_p2][sensor.aqara_motion_and_light_sensor_p2_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Aqara Motion and Light Sensor P2 Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.aqara_motion_and_light_sensor_p2_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.0', + }) +# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6420,6 +6791,115 @@ 'state': '234.899', }) # --- +# name: test_sensors[tado_smart_radiator_thermostat_x][sensor.smart_radiator_thermostat_x_humidity-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.smart_radiator_thermostat_x_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-HumiditySensor-1029-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[tado_smart_radiator_thermostat_x][sensor.smart_radiator_thermostat_x_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Smart Radiator Thermostat X Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smart_radiator_thermostat_x_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.92', + }) +# --- +# name: test_sensors[tado_smart_radiator_thermostat_x][sensor.smart_radiator_thermostat_x_temperature-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.smart_radiator_thermostat_x_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[tado_smart_radiator_thermostat_x][sensor.smart_radiator_thermostat_x_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Smart Radiator Thermostat X Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_radiator_thermostat_x_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.9', + }) +# --- # name: test_sensors[temperature_sensor][sensor.mock_temperature_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6476,6 +6956,62 @@ 'state': '21.0', }) # --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_outdoor_temperature-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.longan_link_hvac_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOutdoorTemperature-513-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Longan link HVAC Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.longan_link_hvac_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- # name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6532,6 +7068,55 @@ 'state': '28.3', }) # --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_estimated_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_vacuum_estimated_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated end time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_end_time', + 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-ServiceAreaEstimatedEndTime-336-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_estimated_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Vacuum Estimated end time', + }), + 'context': , + 'entity_id': 'sensor.mock_vacuum_estimated_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-08-29T21:00:00+00:00', + }) +# --- # name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 01881448e13..d7c2aba92a3 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -146,6 +146,54 @@ 'state': 'off', }) # --- +# name: test_switches[door_lock][switch.mock_door_lock_privacy_mode_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_door_lock_privacy_mode_button', + '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': 'Privacy mode button', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode_button', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockEnablePrivacyModeButton-257-43', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[door_lock][switch.mock_door_lock_privacy_mode_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Privacy mode button', + }), + 'context': , + 'entity_id': 'switch.mock_door_lock_privacy_mode_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[door_lock_with_unbolt][switch.mock_door_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -195,6 +243,54 @@ 'state': 'off', }) # --- +# name: test_switches[door_lock_with_unbolt][switch.mock_door_lock_privacy_mode_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_door_lock_privacy_mode_button', + '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': 'Privacy mode button', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode_button', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-DoorLockEnablePrivacyModeButton-257-43', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[door_lock_with_unbolt][switch.mock_door_lock_privacy_mode_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Privacy mode button', + }), + 'context': , + 'entity_id': 'switch.mock_door_lock_privacy_mode_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[eve_energy_plug][switch.eve_energy_plug-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -390,55 +486,6 @@ 'state': 'off', }) # --- -# name: test_switches[mounted_dimmable_load_control_fixture][switch.mock_mounted_dimmable_load_control-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.mock_mounted_dimmable_load_control', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterSwitch-6-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[mounted_dimmable_load_control_fixture][switch.mock_mounted_dimmable_load_control-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Mock Mounted dimmable load control', - }), - 'context': , - 'entity_id': 'switch.mock_mounted_dimmable_load_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- # name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index fcfd4da84c8..3dcd129514e 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode from matter_server.common.models import EventType import pytest @@ -275,3 +276,100 @@ async def test_dishwasher_alarm( state = hass.states.get("binary_sensor.dishwasher_door_alarm") assert state assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["valve"]) +async def test_water_valve( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test valve alarms.""" + # ValveFault default state + state = hass.states.get("binary_sensor.valve_general_fault") + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.valve_valve_blocked") + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.valve_valve_leaking") + assert state + assert state.state == "off" + + # ValveFault general_fault test + set_node_attribute(matter_node, 1, 129, 9, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.valve_general_fault") + assert state + assert state.state == "on" + + state = hass.states.get("binary_sensor.valve_valve_blocked") + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.valve_valve_leaking") + assert state + assert state.state == "off" + + # ValveFault valve_blocked test + set_node_attribute(matter_node, 1, 129, 9, 2) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.valve_general_fault") + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.valve_valve_blocked") + assert state + assert state.state == "on" + + state = hass.states.get("binary_sensor.valve_valve_leaking") + assert state + assert state.state == "off" + + # ValveFault valve_leaking test + set_node_attribute(matter_node, 1, 129, 9, 4) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.valve_general_fault") + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.valve_valve_blocked") + assert state + assert state.state == "off" + + state = hass.states.get("binary_sensor.valve_valve_leaking") + assert state + assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["thermostat"]) +async def test_thermostat_occupancy( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test thermostat occupancy.""" + state = hass.states.get("binary_sensor.longan_link_hvac_occupancy") + assert state + assert state.state == "on" + + # Test Occupancy attribute change + occupancy_attribute = clusters.Thermostat.Attributes.Occupancy + + set_node_attribute( + matter_node, + 1, + occupancy_attribute.cluster_id, + occupancy_attribute.attribute_id, + 0, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.longan_link_hvac_occupancy") + assert state + assert state.state == "off" diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 7761d5d27da..4e9afb4e696 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -85,6 +85,12 @@ async def test_thermostat_base( assert state assert state.attributes["hvac_action"] == HVACAction.HEATING + set_node_attribute(matter_node, 1, 513, 41, 5) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["hvac_action"] == HVACAction.HEATING + set_node_attribute(matter_node, 1, 513, 41, 8) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") @@ -97,12 +103,24 @@ async def test_thermostat_base( assert state assert state.attributes["hvac_action"] == HVACAction.COOLING + set_node_attribute(matter_node, 1, 513, 41, 6) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["hvac_action"] == HVACAction.COOLING + set_node_attribute(matter_node, 1, 513, 41, 16) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state assert state.attributes["hvac_action"] == HVACAction.COOLING + set_node_attribute(matter_node, 1, 513, 41, 66) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["hvac_action"] == HVACAction.COOLING + set_node_attribute(matter_node, 1, 513, 41, 4) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") @@ -121,7 +139,7 @@ async def test_thermostat_base( assert state assert state.attributes["hvac_action"] == HVACAction.FAN - set_node_attribute(matter_node, 1, 513, 41, 66) + set_node_attribute(matter_node, 1, 513, 41, 128) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state @@ -144,6 +162,59 @@ async def test_thermostat_base( assert state.attributes["temperature"] == 20 +@pytest.mark.parametrize("node_fixture", ["thermostat"]) +async def test_thermostat_humidity( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test thermostat humidity attribute and state updates.""" + # test entity attributes + state = hass.states.get("climate.longan_link_hvac") + assert state + + measured_value = clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue + + # test current humidity update from device + set_node_attribute( + matter_node, + 1, + measured_value.cluster_id, + measured_value.attribute_id, + 1234, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["current_humidity"] == 12.34 + + # test current humidity update from device with zero value + set_node_attribute( + matter_node, + 1, + measured_value.cluster_id, + measured_value.attribute_id, + 0, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["current_humidity"] == 0.0 + + # test current humidity update from device with None value + set_node_attribute( + matter_node, + 1, + measured_value.cluster_id, + measured_value.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert "current_humidity" not in state.attributes + + @pytest.mark.parametrize("node_fixture", ["thermostat"]) async def test_thermostat_service_calls( hass: HomeAssistant, diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index ab3995e6771..e6566202c59 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -4,10 +4,11 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.models import EventType, MatterNodeEvent import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lock import LockEntityFeature, LockState +from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntityFeature, LockState from homeassistant.const import ATTR_CODE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -112,6 +113,26 @@ async def test_lock( state = hass.states.get("lock.mock_door_lock") assert state.attributes["supported_features"] & LockEntityFeature.OPEN + # test handling of a node LockOperation event + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=matter_node.node_id, + endpoint_id=1, + cluster_id=257, + event_id=2, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data={"operationSource": 3}, + ), + ) + state = hass.states.get("lock.mock_door_lock") + assert state.attributes[ATTR_CHANGED_BY] == "Keypad" + @pytest.mark.parametrize("node_fixture", ["door_lock"]) async def test_lock_requires_pin( diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index d35a889a436..bca68179f40 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -212,7 +212,7 @@ async def test_microwave_oven( """Test Cooktime for microwave oven.""" # Cooktime on MicrowaveOvenControl cluster (1/96/2) - state = hass.states.get("number.microwave_oven_cook_time") + state = hass.states.get("number.microwave_oven_cooking_time") assert state assert state.state == "30" @@ -221,7 +221,7 @@ async def test_microwave_oven( "number", "set_value", { - "entity_id": "number.microwave_oven_cook_time", + "entity_id": "number.microwave_oven_cooking_time", "value": 60, # 60 seconds }, blocking=True, @@ -234,3 +234,58 @@ async def test_microwave_oven( cookTime=60, # 60 seconds ), ) + + +@pytest.mark.parametrize("node_fixture", ["door_lock"]) +async def test_lock_attributes( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test door lock attributes.""" + # WrongCodeEntryLimit for door lock + state = hass.states.get("number.mock_door_lock_wrong_code_limit") + assert state + assert state.state == "3" + + set_node_attribute(matter_node, 1, 257, 48, 10) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.mock_door_lock_wrong_code_limit") + assert state + assert state.state == "10" + + # UserCodeTemporaryDisableTime for door lock + state = hass.states.get("number.mock_door_lock_user_code_temporary_disable_time") + assert state + assert state.state == "10" + + set_node_attribute(matter_node, 1, 257, 49, 30) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.mock_door_lock_user_code_temporary_disable_time") + assert state + assert state.state == "30" + + +@pytest.mark.parametrize("node_fixture", ["door_lock"]) +async def test_matter_exception_on_door_lock_write_attribute( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test that MatterError is handled for write_attribute call.""" + entity_id = "number.mock_door_lock_wrong_code_limit" + state = hass.states.get(entity_id) + assert state + matter_client.write_attribute.side_effect = MatterError("Boom!") + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": 1, + }, + blocking=True, + ) + + assert str(exc_info.value) == "Boom!" diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index c264f51b669..19ce9b2185d 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -282,3 +282,23 @@ async def test_microwave_oven( wattSettingIndex=8 ), ) + + +@pytest.mark.parametrize("node_fixture", ["aqara_door_window_p2"]) +async def test_aqara_door_window_p2( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test select entity for Aqara contact sensor fixture.""" + # SensitivityLevel attribute + state = hass.states.get("select.aqara_door_and_window_sensor_p2_sensitivity") + assert state + assert state.state == "30 mm" + assert state.attributes["options"] == ["10 mm", "20 mm", "30 mm"] + + # Change SensitivityLevel to 20 mm + set_node_attribute(matter_node, 1, 128, 0, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.aqara_door_and_window_sensor_p2_sensitivity") + assert state.state == "20 mm" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 883a976284e..2414bafc80d 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -233,6 +233,26 @@ async def test_eve_thermo_sensor( assert state.state == "18.0" +@pytest.mark.parametrize("node_fixture", ["thermostat"]) +async def test_thermostat_outdoor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test OutdoorTemperature.""" + # OutdoorTemperature + state = hass.states.get("sensor.longan_link_hvac_outdoor_temperature") + assert state + assert state.state == "12.5" + + set_node_attribute(matter_node, 1, 513, 1, -550) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.longan_link_hvac_outdoor_temperature") + assert state + assert state.state == "-5.5" + + @pytest.mark.parametrize("node_fixture", ["pressure_sensor"]) async def test_pressure_sensor( hass: HomeAssistant, @@ -583,3 +603,23 @@ async def test_pump( state = hass.states.get("sensor.mock_pump_rotation_speed") assert state assert state.state == "500" + + +@pytest.mark.parametrize("node_fixture", ["vacuum_cleaner"]) +async def test_vacuum_actions( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test vacuum sensors.""" + # EstimatedEndTime + state = hass.states.get("sensor.mock_vacuum_estimated_end_time") + assert state + assert state.state == "2025-08-29T21:00:00+00:00" + + set_node_attribute(matter_node, 1, 336, 4, 1756502000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.mock_vacuum_estimated_end_time") + assert state + assert state.state == "2025-08-29T21:13:20+00:00" diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index e1c8801f51b..9cc9c76f9bd 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -5,11 +5,13 @@ from contextlib import asynccontextmanager from http import HTTPStatus import json import logging +from typing import Any import aiohttp import mcp import mcp.client.session import mcp.client.sse +import mcp.client.streamable_http from mcp.shared.exceptions import McpError import pytest @@ -17,7 +19,11 @@ from homeassistant.components.conversation import DOMAIN as CONVERSATION_DOMAIN from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.mcp_server.const import STATELESS_LLM_API -from homeassistant.components.mcp_server.http import MESSAGES_API, SSE_API +from homeassistant.components.mcp_server.http import ( + MESSAGES_API, + SSE_API, + STREAMABLE_API, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -275,16 +281,26 @@ async def test_http_requires_authentication( assert response.status == HTTPStatus.UNAUTHORIZED +@pytest.fixture(params=["sse", "streamable"]) +def mcp_protocol(request: pytest.FixtureRequest): + """Fixture to parametrize tests with different MCP protocols.""" + return request.param + + @pytest.fixture -async def mcp_sse_url(hass_client: ClientSessionGenerator) -> str: - """Fixture to get the MCP integration SSE URL.""" +async def mcp_url(mcp_protocol: str, hass_client: ClientSessionGenerator) -> str: + """Fixture to get the MCP integration URL.""" + if mcp_protocol == "sse": + url = SSE_API + else: + url = STREAMABLE_API client = await hass_client() - return str(client.make_url(SSE_API)) + return str(client.make_url(url)) @asynccontextmanager -async def mcp_session( - mcp_sse_url: str, +async def mcp_sse_session( + mcp_url: str, hass_supervisor_access_token: str, ) -> AsyncGenerator[mcp.client.session.ClientSession]: """Create an MCP session.""" @@ -292,23 +308,55 @@ async def mcp_session( headers = {"Authorization": f"Bearer {hass_supervisor_access_token}"} async with ( - mcp.client.sse.sse_client(mcp_sse_url, headers=headers) as streams, + mcp.client.sse.sse_client(mcp_url, headers=headers) as streams, mcp.client.session.ClientSession(*streams) as session, ): await session.initialize() yield session +@asynccontextmanager +async def mcp_streamable_session( + mcp_url: str, + hass_supervisor_access_token: str, +) -> AsyncGenerator[mcp.client.session.ClientSession]: + """Create an MCP session.""" + + headers = {"Authorization": f"Bearer {hass_supervisor_access_token}"} + + async with ( + mcp.client.streamable_http.streamablehttp_client(mcp_url, headers=headers) as ( + read_stream, + write_stream, + _, + ), + mcp.client.session.ClientSession(read_stream, write_stream) as session, + ): + await session.initialize() + yield session + + +@pytest.fixture(name="mcp_client") +def mcp_client_fixture(mcp_protocol: str) -> Any: + """Fixture to parametrize tests with different MCP clients.""" + if mcp_protocol == "sse": + return mcp_sse_session + if mcp_protocol == "streamable": + return mcp_streamable_session + raise ValueError(f"Unknown MCP protocol: {mcp_protocol}") + + @pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tools_list( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the tools list endpoint.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.list_tools() # Pick a single arbitrary tool and test that description and parameters @@ -326,7 +374,8 @@ async def test_mcp_tools_list( async def test_mcp_tool_call( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the tool call endpoint.""" @@ -335,7 +384,7 @@ async def test_mcp_tool_call( assert state assert state.state == STATE_OFF - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.call_tool( name="HassTurnOn", arguments={"name": "kitchen light"}, @@ -358,12 +407,13 @@ async def test_mcp_tool_call( async def test_mcp_tool_call_failed( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the tool call endpoint with a failure.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.call_tool( name="HassTurnOn", arguments={"name": "backyard"}, @@ -379,12 +429,13 @@ async def test_mcp_tool_call_failed( async def test_prompt_list( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the list prompt endpoint.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.list_prompts() assert len(result.prompts) == 1 @@ -397,12 +448,13 @@ async def test_prompt_list( async def test_prompt_get( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the get prompt endpoint.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: result = await session.get_prompt(name="Assist") assert result.description == "Default prompt for Home Assistant Assist API" @@ -413,14 +465,15 @@ async def test_prompt_get( assert result.messages[0].content.text.endswith(EXPECTED_PROMPT_SUFFIX) -async def test_get_unknwon_prompt( +async def test_get_unknown_prompt( hass: HomeAssistant, setup_integration: None, - mcp_sse_url: str, + mcp_url: str, + mcp_client: Any, hass_supervisor_access_token: str, ) -> None: """Test the get prompt endpoint.""" - async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + async with mcp_client(mcp_url, hass_supervisor_access_token) as session: with pytest.raises(McpError): await session.get_prompt(name="Unknown") diff --git a/tests/components/mealie/fixtures/about.json b/tests/components/mealie/fixtures/about.json index 86f74ec66d6..1ffac4bdd5a 100644 --- a/tests/components/mealie/fixtures/about.json +++ b/tests/components/mealie/fixtures/about.json @@ -1,3 +1,3 @@ { - "version": "v1.10.2" + "version": "v2.0.0" } diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index c4d649fcec6..94d5ecdeaaa 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -2,7 +2,7 @@ # name: test_entry_diagnostics dict({ 'about': dict({ - 'version': 'v1.10.2', + 'version': 'v2.0.0', }), 'mealplans': dict({ 'breakfast': list([ @@ -23,9 +23,12 @@ 'image': 'JeQ2', 'name': 'Roast Chicken', 'original_url': 'https://tastesbetterfromscratch.com/roast-chicken/', + 'perform_time': '1 Hour 20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '5b055066-d57d-4fd0-8dfd-a2c2f07b36f1', 'recipe_yield': '6 servings', 'slug': 'roast-chicken', + 'total_time': '1 Hour 35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -50,9 +53,12 @@ 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', 'recipe_yield': '2 servings', 'slug': 'zoete-aardappel-curry-traybake', + 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -75,9 +81,12 @@ 'image': 'En9o', 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'perform_time': '50 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', 'recipe_yield': '6 servings', 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -100,9 +109,12 @@ 'image': 'Kn62', 'name': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', 'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/', + 'perform_time': '20 Minutes', + 'prep_time': '40 Minutes', 'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d', 'recipe_yield': '4 servings', 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', + 'total_time': '1 Hour', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -125,9 +137,12 @@ 'image': 'ibL6', 'name': 'Pampered Chef Double Chocolate Mocha Trifle', 'original_url': 'https://www.food.com/recipe/pampered-chef-double-chocolate-mocha-trifle-74963', + 'perform_time': '1 Hour', + 'prep_time': '15 Minutes', 'recipe_id': '92635fd0-f2dc-4e78-a6e4-ecd556ad361f', 'recipe_yield': '12 servings', 'slug': 'pampered-chef-double-chocolate-mocha-trifle', + 'total_time': '1 Hour 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -150,9 +165,12 @@ 'image': 'beGq', 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', + 'perform_time': '22 Minutes', + 'prep_time': '8 Minutes', 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', 'recipe_yield': '24 servings', 'slug': 'cheeseburger-sliders-easy-30-min-recipe', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -175,9 +193,12 @@ 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'perform_time': '3 Hours 10 Minutes', + 'prep_time': '5 Minutes', 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', 'recipe_yield': '6 servings', 'slug': 'all-american-beef-stew-recipe', + 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -200,9 +221,12 @@ 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', 'recipe_yield': '4 servings', 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -225,9 +249,12 @@ 'image': '5G1v', 'name': 'Miso Udon Noodles with Spinach and Tofu', 'original_url': 'https://www.allrecipes.com/recipe/284039/miso-udon-noodles-with-spinach-and-tofu/', + 'perform_time': '15 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': '25b814f2-d9bf-4df0-b40d-d2f2457b4317', 'recipe_yield': '2 servings', 'slug': 'miso-udon-noodles-with-spinach-and-tofu', + 'total_time': '25 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -250,9 +277,12 @@ 'image': 'rrNL', 'name': 'Mousse de saumon', 'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon', + 'perform_time': '2 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb', 'recipe_yield': '12 servings', 'slug': 'mousse-de-saumon', + 'total_time': '17 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -291,9 +321,12 @@ 'image': 'INQz', 'name': 'Receta de pollo al curry en 10 minutos (con vídeo incluido)', 'original_url': 'https://www.directoalpaladar.com/recetas-de-carnes-y-aves/receta-de-pollo-al-curry-en-10-minutos', + 'perform_time': '7 Minutes', + 'prep_time': '3 Minutes', 'recipe_id': 'e360a0cc-18b0-4a84-a91b-8aa59e2451c9', 'recipe_yield': '2 servings', 'slug': 'receta-de-pollo-al-curry-en-10-minutos-con-video-incluido', + 'total_time': '10 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -316,9 +349,12 @@ 'image': 'nj5M', 'name': 'Boeuf bourguignon : la vraie recette (2)', 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'perform_time': '4 Hours', + 'prep_time': '1 Hour', 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', 'recipe_yield': '4 servings', 'slug': 'boeuf-bourguignon-la-vraie-recette-2', + 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -341,9 +377,12 @@ 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'perform_time': '3 Hours 10 Minutes', + 'prep_time': '5 Minutes', 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', 'recipe_yield': '6 servings', 'slug': 'all-american-beef-stew-recipe', + 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -368,9 +407,12 @@ 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', 'recipe_yield': '4 servings', 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index 50da06ca005..18824686aba 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -26,7 +26,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'sw_version': 'v1.10.2', + 'sw_version': 'v2.0.0', 'via_device_id': None, }) # --- diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index a1cb758098e..7ec3fc6139e 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -10,9 +10,12 @@ 'image': None, 'name': 'tu6y', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'e82f5449-c33b-437c-b712-337587199264', 'recipe_yield': None, 'slug': 'tu6y', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -22,9 +25,12 @@ 'image': 'En9o', 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'perform_time': '50 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', 'recipe_yield': '6 servings', 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -34,9 +40,12 @@ 'image': 'aAhk', 'name': 'Patates douces au four (1)', 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b', 'recipe_yield': '', 'slug': 'patates-douces-au-four-1', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -46,9 +55,12 @@ 'image': 'kdhm', 'name': 'Sweet potatoes', 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '98845807-9365-41fd-acd1-35630b468c27', 'recipe_yield': '', 'slug': 'sweet-potatoes', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -58,9 +70,12 @@ 'image': 'tNbG', 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο', 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'perform_time': '50 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7', 'recipe_yield': '6 servings', 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -70,9 +85,12 @@ 'image': 'nj5M', 'name': 'Boeuf bourguignon : la vraie recette (2)', 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'perform_time': '4 Hours', + 'prep_time': '1 Hour', 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', 'recipe_yield': '4 servings', 'slug': 'boeuf-bourguignon-la-vraie-recette-2', + 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -82,9 +100,12 @@ 'image': 'rbU7', 'name': 'Boeuf bourguignon : la vraie recette (1)', 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'perform_time': '4 Hours', + 'prep_time': '1 Hour', 'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b', 'recipe_yield': '4 servings', 'slug': 'boeuf-bourguignon-la-vraie-recette-1', + 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -94,9 +115,12 @@ 'image': 'JSp3', 'name': 'Veganes Marmor-Bananenbrot mit Erdnussbutter', 'original_url': 'https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/', + 'perform_time': '55 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91', 'recipe_yield': '14 servings', 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -106,9 +130,12 @@ 'image': '9QMh', 'name': 'Pasta mit Tomaten, Knoblauch und Basilikum - einfach (und) genial! - Kuechenchaotin', 'original_url': 'https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51', 'recipe_yield': '', 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -118,9 +145,12 @@ 'image': None, 'name': 'test123', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542', 'recipe_yield': None, 'slug': 'test123', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -130,9 +160,12 @@ 'image': None, 'name': 'Bureeto', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78', 'recipe_yield': None, 'slug': 'bureeto', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -142,9 +175,12 @@ 'image': None, 'name': 'Subway Double Cookies', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0', 'recipe_yield': None, 'slug': 'subway-double-cookies', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -154,9 +190,12 @@ 'image': None, 'name': 'qwerty12345', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4', 'recipe_yield': None, 'slug': 'qwerty12345', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -166,9 +205,12 @@ 'image': 'beGq', 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', + 'perform_time': '22 Minutes', + 'prep_time': '8 Minutes', 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', 'recipe_yield': '24 servings', 'slug': 'cheeseburger-sliders-easy-30-min-recipe', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -178,9 +220,12 @@ 'image': None, 'name': 'meatloaf', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291', 'recipe_yield': '4', 'slug': 'meatloaf', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -190,9 +235,12 @@ 'image': 'kCBh', 'name': 'Richtig rheinischer Sauerbraten', 'original_url': 'https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html', + 'perform_time': '2 Hours 20 Minutes', + 'prep_time': '1 Hour', 'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117', 'recipe_yield': '4 servings', 'slug': 'richtig-rheinischer-sauerbraten', + 'total_time': '3 Hours 20 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -202,9 +250,12 @@ 'image': 'kpBx', 'name': 'Orientalischer Gemüse-Hähnchen Eintopf', 'original_url': 'https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022', 'recipe_yield': '6 servings', 'slug': 'orientalischer-gemuse-hahnchen-eintopf', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -214,9 +265,12 @@ 'image': None, 'name': 'test 20240121', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d', 'recipe_yield': '4', 'slug': 'test-20240121', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -226,9 +280,12 @@ 'image': 'McEx', 'name': 'Loempia bowl', 'original_url': 'https://www.lekkerensimpel.com/loempia-bowl/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164', 'recipe_yield': '', 'slug': 'loempia-bowl', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -238,9 +295,12 @@ 'image': 'bzqo', 'name': '5 Ingredient Chocolate Mousse', 'original_url': 'https://thehappypear.ie/aquafaba-chocolate-mousse/', + 'perform_time': None, + 'prep_time': '10 Minutes', 'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4', 'recipe_yield': '6 servings', 'slug': '5-ingredient-chocolate-mousse', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -250,9 +310,12 @@ 'image': 'KGK6', 'name': 'Der perfekte Pfannkuchen - gelingt einfach immer', 'original_url': 'https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html', + 'perform_time': '10 Minutes', + 'prep_time': '5 Minutes', 'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c', 'recipe_yield': '4 servings', 'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer', + 'total_time': '15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -262,9 +325,12 @@ 'image': 'yNDq', 'name': 'Dinkel-Sauerteigbrot', 'original_url': 'https://www.besondersgut.ch/dinkel-sauerteigbrot/', + 'perform_time': '35min', + 'prep_time': '1h', 'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842', 'recipe_yield': '1', 'slug': 'dinkel-sauerteigbrot', + 'total_time': '24h', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -274,9 +340,12 @@ 'image': None, 'name': 'test 234234', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37', 'recipe_yield': None, 'slug': 'test-234234', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -286,9 +355,12 @@ 'image': None, 'name': 'test 243', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688', 'recipe_yield': None, 'slug': 'test-243', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -298,9 +370,12 @@ 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', 'recipe_yield': '4 servings', 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -321,9 +396,12 @@ 'image': 'vxuL', 'name': 'Tarta cytrynowa z bezą', 'original_url': 'https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza', + 'perform_time': None, + 'prep_time': '1 Hour', 'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554', 'recipe_yield': '8 servings', 'slug': 'tarta-cytrynowa-z-beza', + 'total_time': '1 Hour', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -333,9 +411,12 @@ 'image': None, 'name': 'Martins test Recipe', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c', 'recipe_yield': None, 'slug': 'martins-test-recipe', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -345,9 +426,12 @@ 'image': 'xP1Q', 'name': 'Muffinki czekoladowe', 'original_url': 'https://aniagotuje.pl/przepis/muffinki-czekoladowe', + 'perform_time': '30 Minutes', + 'prep_time': '25 Minutes', 'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69', 'recipe_yield': '12', 'slug': 'muffinki-czekoladowe', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -357,9 +441,12 @@ 'image': None, 'name': 'My Test Recipe', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf', 'recipe_yield': None, 'slug': 'my-test-recipe', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -369,9 +456,12 @@ 'image': None, 'name': 'My Test Receipe', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816', 'recipe_yield': None, 'slug': 'my-test-receipe', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -381,9 +471,12 @@ 'image': 'r1ck', 'name': 'Patates douces au four', 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f', 'recipe_yield': '', 'slug': 'patates-douces-au-four', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -393,9 +486,12 @@ 'image': 'gD94', 'name': 'Easy Homemade Pizza Dough', 'original_url': 'https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/', + 'perform_time': '15 Minutes', + 'prep_time': '2 Hours 15 Minutes', 'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec', 'recipe_yield': '2 servings', 'slug': 'easy-homemade-pizza-dough', + 'total_time': '2 Hours 30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -405,9 +501,12 @@ 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'perform_time': '3 Hours 10 Minutes', + 'prep_time': '5 Minutes', 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', 'recipe_yield': '6 servings', 'slug': 'all-american-beef-stew-recipe', + 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -417,9 +516,12 @@ 'image': '4Sys', 'name': "Serious Eats' Halal Cart-Style Chicken and Rice With White Sauce", 'original_url': 'https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe', + 'perform_time': '55 Minutes', + 'prep_time': '20 Minutes', 'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c', 'recipe_yield': '4 servings', 'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce', + 'total_time': '2 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -429,9 +531,12 @@ 'image': '8goY', 'name': 'Schnelle Käsespätzle', 'original_url': 'https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html', + 'perform_time': '30 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e', 'recipe_yield': '4 servings', 'slug': 'schnelle-kasespatzle', + 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -441,9 +546,12 @@ 'image': None, 'name': 'taco', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3', 'recipe_yield': None, 'slug': 'taco', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -453,9 +561,12 @@ 'image': 'z8BB', 'name': 'Vodkapasta', 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588', 'recipe_yield': '4 servings', 'slug': 'vodkapasta', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -465,9 +576,12 @@ 'image': 'Nqpz', 'name': 'Vodkapasta2', 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733', 'recipe_yield': '4 servings', 'slug': 'vodkapasta2', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -477,9 +591,12 @@ 'image': None, 'name': 'Rub', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670', 'recipe_yield': '1', 'slug': 'rub', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -489,9 +606,12 @@ 'image': '03XS', 'name': 'Banana Bread Chocolate Chip Cookies', 'original_url': 'https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html', + 'perform_time': '15 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523', 'recipe_yield': '', 'slug': 'banana-bread-chocolate-chip-cookies', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -501,9 +621,12 @@ 'image': 'KuXV', 'name': 'Cauliflower Bisque Recipe with Cheddar Cheese', 'original_url': 'https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041', 'recipe_yield': '', 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -513,9 +636,12 @@ 'image': None, 'name': 'Prova ', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a', 'recipe_yield': '', 'slug': 'prova', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -525,9 +651,12 @@ 'image': None, 'name': 'pate au beurre (1)', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4', 'recipe_yield': None, 'slug': 'pate-au-beurre-1', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -537,9 +666,12 @@ 'image': None, 'name': 'pate au beurre', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c', 'recipe_yield': None, 'slug': 'pate-au-beurre', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -549,9 +681,12 @@ 'image': 'tmwm', 'name': 'Sous Vide Cheesecake Recipe', 'original_url': 'https://saltpepperskillet.com/recipes/sous-vide-cheesecake/', + 'perform_time': '1 Hour 30 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517', 'recipe_yield': '4 servings', 'slug': 'sous-vide-cheesecake-recipe', + 'total_time': '2 Hours 10 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -561,9 +696,12 @@ 'image': 'xCYc', 'name': 'The Bomb Mini Cheesecakes', 'original_url': 'https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes', + 'perform_time': None, + 'prep_time': '30 Minutes', 'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b', 'recipe_yield': '10 servings', 'slug': 'the-bomb-mini-cheesecakes', + 'total_time': '1 Hour 30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -573,9 +711,12 @@ 'image': 'qzaN', 'name': 'Tagliatelle al Salmone', 'original_url': 'https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html', + 'perform_time': '15 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee', 'recipe_yield': '4 servings', 'slug': 'tagliatelle-al-salmone', + 'total_time': '25 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -585,9 +726,12 @@ 'image': 'K9qP', 'name': 'Death by Chocolate', 'original_url': 'https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html', + 'perform_time': '25 Minutes', + 'prep_time': '25 Minutes', 'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627', 'recipe_yield': '1 serving', 'slug': 'death-by-chocolate', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -597,9 +741,12 @@ 'image': 'jKQ3', 'name': 'Palak Dal Rezept aus Indien', 'original_url': 'https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/', + 'perform_time': '20 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c', 'recipe_yield': '4 servings', 'slug': 'palak-dal-rezept-aus-indien', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -609,9 +756,12 @@ 'image': 'rkSn', 'name': 'Tortelline - á la Romana', 'original_url': 'https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html', + 'perform_time': None, + 'prep_time': '30 Minutes', 'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3', 'recipe_yield': '4 servings', 'slug': 'tortelline-a-la-romana', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), ]), @@ -629,9 +779,12 @@ 'image': None, 'name': 'tu6y', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'e82f5449-c33b-437c-b712-337587199264', 'recipe_yield': None, 'slug': 'tu6y', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -641,9 +794,12 @@ 'image': 'En9o', 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'perform_time': '50 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', 'recipe_yield': '6 servings', 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -653,9 +809,12 @@ 'image': 'aAhk', 'name': 'Patates douces au four (1)', 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b', 'recipe_yield': '', 'slug': 'patates-douces-au-four-1', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -665,9 +824,12 @@ 'image': 'kdhm', 'name': 'Sweet potatoes', 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '98845807-9365-41fd-acd1-35630b468c27', 'recipe_yield': '', 'slug': 'sweet-potatoes', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -677,9 +839,12 @@ 'image': 'tNbG', 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο', 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'perform_time': '50 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7', 'recipe_yield': '6 servings', 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -689,9 +854,12 @@ 'image': 'nj5M', 'name': 'Boeuf bourguignon : la vraie recette (2)', 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'perform_time': '4 Hours', + 'prep_time': '1 Hour', 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', 'recipe_yield': '4 servings', 'slug': 'boeuf-bourguignon-la-vraie-recette-2', + 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -701,9 +869,12 @@ 'image': 'rbU7', 'name': 'Boeuf bourguignon : la vraie recette (1)', 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'perform_time': '4 Hours', + 'prep_time': '1 Hour', 'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b', 'recipe_yield': '4 servings', 'slug': 'boeuf-bourguignon-la-vraie-recette-1', + 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -713,9 +884,12 @@ 'image': 'JSp3', 'name': 'Veganes Marmor-Bananenbrot mit Erdnussbutter', 'original_url': 'https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/', + 'perform_time': '55 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91', 'recipe_yield': '14 servings', 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -725,9 +899,12 @@ 'image': '9QMh', 'name': 'Pasta mit Tomaten, Knoblauch und Basilikum - einfach (und) genial! - Kuechenchaotin', 'original_url': 'https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51', 'recipe_yield': '', 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -737,9 +914,12 @@ 'image': None, 'name': 'test123', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542', 'recipe_yield': None, 'slug': 'test123', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -749,9 +929,12 @@ 'image': None, 'name': 'Bureeto', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78', 'recipe_yield': None, 'slug': 'bureeto', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -761,9 +944,12 @@ 'image': None, 'name': 'Subway Double Cookies', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0', 'recipe_yield': None, 'slug': 'subway-double-cookies', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -773,9 +959,12 @@ 'image': None, 'name': 'qwerty12345', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4', 'recipe_yield': None, 'slug': 'qwerty12345', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -785,9 +974,12 @@ 'image': 'beGq', 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', + 'perform_time': '22 Minutes', + 'prep_time': '8 Minutes', 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', 'recipe_yield': '24 servings', 'slug': 'cheeseburger-sliders-easy-30-min-recipe', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -797,9 +989,12 @@ 'image': None, 'name': 'meatloaf', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291', 'recipe_yield': '4', 'slug': 'meatloaf', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -809,9 +1004,12 @@ 'image': 'kCBh', 'name': 'Richtig rheinischer Sauerbraten', 'original_url': 'https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html', + 'perform_time': '2 Hours 20 Minutes', + 'prep_time': '1 Hour', 'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117', 'recipe_yield': '4 servings', 'slug': 'richtig-rheinischer-sauerbraten', + 'total_time': '3 Hours 20 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -821,9 +1019,12 @@ 'image': 'kpBx', 'name': 'Orientalischer Gemüse-Hähnchen Eintopf', 'original_url': 'https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022', 'recipe_yield': '6 servings', 'slug': 'orientalischer-gemuse-hahnchen-eintopf', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -833,9 +1034,12 @@ 'image': None, 'name': 'test 20240121', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d', 'recipe_yield': '4', 'slug': 'test-20240121', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -845,9 +1049,12 @@ 'image': 'McEx', 'name': 'Loempia bowl', 'original_url': 'https://www.lekkerensimpel.com/loempia-bowl/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164', 'recipe_yield': '', 'slug': 'loempia-bowl', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -857,9 +1064,12 @@ 'image': 'bzqo', 'name': '5 Ingredient Chocolate Mousse', 'original_url': 'https://thehappypear.ie/aquafaba-chocolate-mousse/', + 'perform_time': None, + 'prep_time': '10 Minutes', 'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4', 'recipe_yield': '6 servings', 'slug': '5-ingredient-chocolate-mousse', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -869,9 +1079,12 @@ 'image': 'KGK6', 'name': 'Der perfekte Pfannkuchen - gelingt einfach immer', 'original_url': 'https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html', + 'perform_time': '10 Minutes', + 'prep_time': '5 Minutes', 'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c', 'recipe_yield': '4 servings', 'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer', + 'total_time': '15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -881,9 +1094,12 @@ 'image': 'yNDq', 'name': 'Dinkel-Sauerteigbrot', 'original_url': 'https://www.besondersgut.ch/dinkel-sauerteigbrot/', + 'perform_time': '35min', + 'prep_time': '1h', 'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842', 'recipe_yield': '1', 'slug': 'dinkel-sauerteigbrot', + 'total_time': '24h', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -893,9 +1109,12 @@ 'image': None, 'name': 'test 234234', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37', 'recipe_yield': None, 'slug': 'test-234234', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -905,9 +1124,12 @@ 'image': None, 'name': 'test 243', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688', 'recipe_yield': None, 'slug': 'test-243', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -917,9 +1139,12 @@ 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', 'recipe_yield': '4 servings', 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -940,9 +1165,12 @@ 'image': 'vxuL', 'name': 'Tarta cytrynowa z bezą', 'original_url': 'https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza', + 'perform_time': None, + 'prep_time': '1 Hour', 'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554', 'recipe_yield': '8 servings', 'slug': 'tarta-cytrynowa-z-beza', + 'total_time': '1 Hour', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -952,9 +1180,12 @@ 'image': None, 'name': 'Martins test Recipe', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c', 'recipe_yield': None, 'slug': 'martins-test-recipe', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -964,9 +1195,12 @@ 'image': 'xP1Q', 'name': 'Muffinki czekoladowe', 'original_url': 'https://aniagotuje.pl/przepis/muffinki-czekoladowe', + 'perform_time': '30 Minutes', + 'prep_time': '25 Minutes', 'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69', 'recipe_yield': '12', 'slug': 'muffinki-czekoladowe', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -976,9 +1210,12 @@ 'image': None, 'name': 'My Test Recipe', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf', 'recipe_yield': None, 'slug': 'my-test-recipe', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -988,9 +1225,12 @@ 'image': None, 'name': 'My Test Receipe', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816', 'recipe_yield': None, 'slug': 'my-test-receipe', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1000,9 +1240,12 @@ 'image': 'r1ck', 'name': 'Patates douces au four', 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f', 'recipe_yield': '', 'slug': 'patates-douces-au-four', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1012,9 +1255,12 @@ 'image': 'gD94', 'name': 'Easy Homemade Pizza Dough', 'original_url': 'https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/', + 'perform_time': '15 Minutes', + 'prep_time': '2 Hours 15 Minutes', 'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec', 'recipe_yield': '2 servings', 'slug': 'easy-homemade-pizza-dough', + 'total_time': '2 Hours 30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1024,9 +1270,12 @@ 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'perform_time': '3 Hours 10 Minutes', + 'prep_time': '5 Minutes', 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', 'recipe_yield': '6 servings', 'slug': 'all-american-beef-stew-recipe', + 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1036,9 +1285,12 @@ 'image': '4Sys', 'name': "Serious Eats' Halal Cart-Style Chicken and Rice With White Sauce", 'original_url': 'https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe', + 'perform_time': '55 Minutes', + 'prep_time': '20 Minutes', 'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c', 'recipe_yield': '4 servings', 'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce', + 'total_time': '2 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1048,9 +1300,12 @@ 'image': '8goY', 'name': 'Schnelle Käsespätzle', 'original_url': 'https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html', + 'perform_time': '30 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e', 'recipe_yield': '4 servings', 'slug': 'schnelle-kasespatzle', + 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1060,9 +1315,12 @@ 'image': None, 'name': 'taco', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3', 'recipe_yield': None, 'slug': 'taco', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1072,9 +1330,12 @@ 'image': 'z8BB', 'name': 'Vodkapasta', 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588', 'recipe_yield': '4 servings', 'slug': 'vodkapasta', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1084,9 +1345,12 @@ 'image': 'Nqpz', 'name': 'Vodkapasta2', 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733', 'recipe_yield': '4 servings', 'slug': 'vodkapasta2', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1096,9 +1360,12 @@ 'image': None, 'name': 'Rub', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670', 'recipe_yield': '1', 'slug': 'rub', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1108,9 +1375,12 @@ 'image': '03XS', 'name': 'Banana Bread Chocolate Chip Cookies', 'original_url': 'https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html', + 'perform_time': '15 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523', 'recipe_yield': '', 'slug': 'banana-bread-chocolate-chip-cookies', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1120,9 +1390,12 @@ 'image': 'KuXV', 'name': 'Cauliflower Bisque Recipe with Cheddar Cheese', 'original_url': 'https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041', 'recipe_yield': '', 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1132,9 +1405,12 @@ 'image': None, 'name': 'Prova ', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a', 'recipe_yield': '', 'slug': 'prova', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1144,9 +1420,12 @@ 'image': None, 'name': 'pate au beurre (1)', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4', 'recipe_yield': None, 'slug': 'pate-au-beurre-1', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1156,9 +1435,12 @@ 'image': None, 'name': 'pate au beurre', 'original_url': None, + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c', 'recipe_yield': None, 'slug': 'pate-au-beurre', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1168,9 +1450,12 @@ 'image': 'tmwm', 'name': 'Sous Vide Cheesecake Recipe', 'original_url': 'https://saltpepperskillet.com/recipes/sous-vide-cheesecake/', + 'perform_time': '1 Hour 30 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517', 'recipe_yield': '4 servings', 'slug': 'sous-vide-cheesecake-recipe', + 'total_time': '2 Hours 10 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1180,9 +1465,12 @@ 'image': 'xCYc', 'name': 'The Bomb Mini Cheesecakes', 'original_url': 'https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes', + 'perform_time': None, + 'prep_time': '30 Minutes', 'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b', 'recipe_yield': '10 servings', 'slug': 'the-bomb-mini-cheesecakes', + 'total_time': '1 Hour 30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1192,9 +1480,12 @@ 'image': 'qzaN', 'name': 'Tagliatelle al Salmone', 'original_url': 'https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html', + 'perform_time': '15 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee', 'recipe_yield': '4 servings', 'slug': 'tagliatelle-al-salmone', + 'total_time': '25 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1204,9 +1495,12 @@ 'image': 'K9qP', 'name': 'Death by Chocolate', 'original_url': 'https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html', + 'perform_time': '25 Minutes', + 'prep_time': '25 Minutes', 'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627', 'recipe_yield': '1 serving', 'slug': 'death-by-chocolate', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1216,9 +1510,12 @@ 'image': 'jKQ3', 'name': 'Palak Dal Rezept aus Indien', 'original_url': 'https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/', + 'perform_time': '20 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c', 'recipe_yield': '4 servings', 'slug': 'palak-dal-rezept-aus-indien', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ @@ -1228,9 +1525,12 @@ 'image': 'rkSn', 'name': 'Tortelline - á la Romana', 'original_url': 'https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html', + 'perform_time': None, + 'prep_time': '30 Minutes', 'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3', 'recipe_yield': '4 servings', 'slug': 'tortelline-a-la-romana', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), ]), @@ -1384,6 +1684,8 @@ ]), 'name': 'Original Sacher-Torte (2)', 'original_url': 'https://www.sacher.com/en/original-sacher-torte/recipe/', + 'perform_time': '1 hour', + 'prep_time': '1 hour 30 minutes', 'recipe_id': 'fada9582-709b-46aa-b384-d5952123ad93', 'recipe_yield': '4 servings', 'slug': 'original-sacher-torte-2', @@ -1424,6 +1726,7 @@ 'tag_id': 'd530b8e4-275a-4093-804b-6d0de154c206', }), ]), + 'total_time': '2 hours 30 minutes', 'user_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0', }), }) @@ -1436,7 +1739,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", @@ -1445,9 +1748,12 @@ 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', 'recipe_yield': '2 servings', 'slug': 'zoete-aardappel-curry-traybake', + 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1458,7 +1764,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 229, 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', @@ -1467,9 +1773,12 @@ 'image': 'JeQ2', 'name': 'Roast Chicken', 'original_url': 'https://tastesbetterfromscratch.com/roast-chicken/', + 'perform_time': '1 Hour 20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '5b055066-d57d-4fd0-8dfd-a2c2f07b36f1', 'recipe_yield': '6 servings', 'slug': 'roast-chicken', + 'total_time': '1 Hour 35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1480,7 +1789,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 226, 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', @@ -1489,9 +1798,12 @@ 'image': 'INQz', 'name': 'Receta de pollo al curry en 10 minutos (con vídeo incluido)', 'original_url': 'https://www.directoalpaladar.com/recetas-de-carnes-y-aves/receta-de-pollo-al-curry-en-10-minutos', + 'perform_time': '7 Minutes', + 'prep_time': '3 Minutes', 'recipe_id': 'e360a0cc-18b0-4a84-a91b-8aa59e2451c9', 'recipe_yield': '2 servings', 'slug': 'receta-de-pollo-al-curry-en-10-minutos-con-video-incluido', + 'total_time': '10 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1502,7 +1814,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 224, 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', @@ -1511,9 +1823,12 @@ 'image': 'nj5M', 'name': 'Boeuf bourguignon : la vraie recette (2)', 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'perform_time': '4 Hours', + 'prep_time': '1 Hour', 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', 'recipe_yield': '4 servings', 'slug': 'boeuf-bourguignon-la-vraie-recette-2', + 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1524,7 +1839,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 222, 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', @@ -1533,9 +1848,12 @@ 'image': 'En9o', 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'perform_time': '50 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', 'recipe_yield': '6 servings', 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', + 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1546,7 +1864,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 221, 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', @@ -1555,9 +1873,12 @@ 'image': 'Kn62', 'name': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', 'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/', + 'perform_time': '20 Minutes', + 'prep_time': '40 Minutes', 'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d', 'recipe_yield': '4 servings', 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', + 'total_time': '1 Hour', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1568,7 +1889,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 220, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', @@ -1577,9 +1898,12 @@ 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', 'recipe_yield': '4 servings', 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1590,7 +1914,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 219, 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', @@ -1599,9 +1923,12 @@ 'image': 'ibL6', 'name': 'Pampered Chef Double Chocolate Mocha Trifle', 'original_url': 'https://www.food.com/recipe/pampered-chef-double-chocolate-mocha-trifle-74963', + 'perform_time': '1 Hour', + 'prep_time': '15 Minutes', 'recipe_id': '92635fd0-f2dc-4e78-a6e4-ecd556ad361f', 'recipe_yield': '12 servings', 'slug': 'pampered-chef-double-chocolate-mocha-trifle', + 'total_time': '1 Hour 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1612,7 +1939,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 217, 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', @@ -1621,9 +1948,12 @@ 'image': 'beGq', 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', + 'perform_time': '22 Minutes', + 'prep_time': '8 Minutes', 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', 'recipe_yield': '24 servings', 'slug': 'cheeseburger-sliders-easy-30-min-recipe', + 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1634,7 +1964,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 216, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', @@ -1643,9 +1973,12 @@ 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'perform_time': '3 Hours 10 Minutes', + 'prep_time': '5 Minutes', 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', 'recipe_yield': '6 servings', 'slug': 'all-american-beef-stew-recipe', + 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1656,7 +1989,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 212, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', @@ -1665,9 +1998,12 @@ 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'perform_time': '3 Hours 10 Minutes', + 'prep_time': '5 Minutes', 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', 'recipe_yield': '6 servings', 'slug': 'all-american-beef-stew-recipe', + 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1678,7 +2014,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 211, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', @@ -1687,9 +2023,12 @@ 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'perform_time': '20 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', 'recipe_yield': '4 servings', 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1700,7 +2039,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 196, 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', @@ -1709,9 +2048,12 @@ 'image': '5G1v', 'name': 'Miso Udon Noodles with Spinach and Tofu', 'original_url': 'https://www.allrecipes.com/recipe/284039/miso-udon-noodles-with-spinach-and-tofu/', + 'perform_time': '15 Minutes', + 'prep_time': '10 Minutes', 'recipe_id': '25b814f2-d9bf-4df0-b40d-d2f2457b4317', 'recipe_yield': '2 servings', 'slug': 'miso-udon-noodles-with-spinach-and-tofu', + 'total_time': '25 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1722,7 +2064,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 195, 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', @@ -1731,9 +2073,12 @@ 'image': 'rrNL', 'name': 'Mousse de saumon', 'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon', + 'perform_time': '2 Minutes', + 'prep_time': '15 Minutes', 'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb', 'recipe_yield': '12 servings', 'slug': 'mousse-de-saumon', + 'total_time': '17 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1744,7 +2089,7 @@ 'entry_type': , 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 21), + 'mealplan_date': HAFakeDate(2024, 1, 21), 'mealplan_id': 1, 'recipe': None, 'title': 'Aquavite', @@ -1900,6 +2245,8 @@ ]), 'name': 'Original Sacher-Torte (2)', 'original_url': 'https://www.sacher.com/en/original-sacher-torte/recipe/', + 'perform_time': '1 hour', + 'prep_time': '1 hour 30 minutes', 'recipe_id': 'fada9582-709b-46aa-b384-d5952123ad93', 'recipe_yield': '4 servings', 'slug': 'original-sacher-torte-2', @@ -1940,6 +2287,7 @@ 'tag_id': 'd530b8e4-275a-4093-804b-6d0de154c206', }), ]), + 'total_time': '2 hours 30 minutes', 'user_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0', }), }) @@ -1960,9 +2308,12 @@ 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', 'recipe_yield': '2 servings', 'slug': 'zoete-aardappel-curry-traybake', + 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -1986,9 +2337,12 @@ 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', 'recipe_yield': '2 servings', 'slug': 'zoete-aardappel-curry-traybake', + 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -2012,9 +2366,12 @@ 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', 'recipe_yield': '2 servings', 'slug': 'zoete-aardappel-curry-traybake', + 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, @@ -2038,9 +2395,12 @@ 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', 'recipe_yield': '2 servings', 'slug': 'zoete-aardappel-curry-traybake', + 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, diff --git a/tests/components/mealie/test_config_flow.py b/tests/components/mealie/test_config_flow.py index 628f0290f43..d4ff9ec8e73 100644 --- a/tests/components/mealie/test_config_flow.py +++ b/tests/components/mealie/test_config_flow.py @@ -6,10 +6,11 @@ from aiomealie import About, MealieAuthenticationError, MealieConnectionError import pytest from homeassistant.components.mealie.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from . import setup_integration @@ -125,6 +126,8 @@ async def test_ingress_host( ("v1.0.0beta-5"), ("v1.0.0-RC2"), ("v0.1.0"), + ("v1.9.0"), + ("v2.0.0beta-2"), ], ) async def test_flow_version_error( @@ -361,3 +364,137 @@ async def test_reconfigure_flow_exceptions( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" + + +async def test_hassio_success( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful Supervisor flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=HassioServiceInfo( + config={"addon": "Mealie", "host": "http://test", "port": 9090}, + name="mealie", + slug="mealie", + uuid="1234", + ), + context={"source": SOURCE_HASSIO}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "hassio_confirm" + assert result.get("description_placeholders") == {"addon": "Mealie"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Mealie" + assert result["data"] == { + CONF_HOST: "http://test:9090", + CONF_API_TOKEN: "token", + CONF_VERIFY_SSL: True, + } + assert result["result"].unique_id == "bf1c62fe-4941-4332-9886-e54e88dbdba0" + + +async def test_hassio_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we only allow a single config flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=HassioServiceInfo( + config={ + "addon": "Mealie", + "host": "mock-mealie", + "port": "9090", + }, + name="Mealie", + slug="mealie", + uuid="1234", + ), + context={"source": SOURCE_HASSIO}, + ) + assert result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_hassio_ignored(hass: HomeAssistant) -> None: + """Test the supervisor discovered instance can be ignored.""" + MockConfigEntry(domain=DOMAIN, source=SOURCE_IGNORE).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=HassioServiceInfo( + config={ + "addon": "Mealie", + "host": "mock-mealie", + "port": "9090", + }, + name="Mealie", + slug="mealie", + uuid="1234", + ), + context={"source": SOURCE_HASSIO}, + ) + assert result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MealieConnectionError, "cannot_connect"), + (MealieAuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_hassio_connection_error( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_mealie_client.get_user_info.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=HassioServiceInfo( + config={"addon": "Mealie", "host": "http://test", "port": 9090}, + name="mealie", + slug="mealie", + uuid="1234", + ), + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "Mealie"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_mealie_client.get_user_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 2e270eb3b2e..2d472d0595b 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -1,8 +1,6 @@ """Test the base functions of the media player.""" -from enum import Enum from http import HTTPStatus -from types import ModuleType from unittest.mock import patch import pytest @@ -18,7 +16,6 @@ from homeassistant.components.media_player import ( MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, - MediaPlayerEntityFeature, SearchMedia, SearchMediaQuery, ) @@ -31,11 +28,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import ( - MockEntityPlatform, - help_test_all, - import_and_test_deprecated_constant_enum, -) +from tests.common import MockEntityPlatform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -46,72 +39,6 @@ async def setup_homeassistant(hass: HomeAssistant): await async_setup_component(hass, "homeassistant", {}) -def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, str]]: - return [ - (enum_field, constant_prefix) - for enum_field in enum - if enum_field - not in [ - MediaPlayerEntityFeature.MEDIA_ANNOUNCE, - MediaPlayerEntityFeature.MEDIA_ENQUEUE, - MediaPlayerEntityFeature.SEARCH_MEDIA, - ] - ] - - -@pytest.mark.parametrize( - "module", - [media_player, media_player.const], -) -def test_all(module: ModuleType) -> None: - """Test module.__all__ is correctly set.""" - help_test_all(module) - - -@pytest.mark.parametrize( - ("enum", "constant_prefix"), - _create_tuples(media_player.MediaPlayerEntityFeature, "SUPPORT_") - + _create_tuples(media_player.MediaPlayerDeviceClass, "DEVICE_CLASS_"), -) -@pytest.mark.parametrize( - "module", - [media_player], -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, - module: ModuleType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, enum, constant_prefix, "2025.10" - ) - - -@pytest.mark.parametrize( - ("enum", "constant_prefix"), - _create_tuples(media_player.MediaClass, "MEDIA_CLASS_") - + _create_tuples(media_player.MediaPlayerEntityFeature, "SUPPORT_") - + _create_tuples(media_player.MediaType, "MEDIA_TYPE_") - + _create_tuples(media_player.RepeatMode, "REPEAT_MODE_"), -) -@pytest.mark.parametrize( - "module", - [media_player.const], -) -def test_deprecated_constants_const( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, - module: ModuleType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, enum, constant_prefix, "2025.10" - ) - - @pytest.mark.parametrize( "property_suffix", [ @@ -654,3 +581,56 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +async def test_play_media_via_selector(hass: HomeAssistant) -> None: + """Test that play_media data under 'media' is remapped to top level keys for backward compatibility.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + # Fake group support for DemoYoutubePlayer + with patch( + "homeassistant.components.demo.media_player.DemoYoutubePlayer.play_media", + ) as mock_play_media: + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": "media_player.bedroom", + "media": { + "media_content_type": "music", + "media_content_id": "1234", + }, + }, + blocking=True, + ) + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": "media_player.bedroom", + "media_content_type": "music", + "media_content_id": "1234", + }, + blocking=True, + ) + + assert len(mock_play_media.mock_calls) == 2 + assert mock_play_media.mock_calls[0].args == mock_play_media.mock_calls[1].args + + with pytest.raises(vol.Invalid, match="Play media cannot contain 'media'"): + await hass.services.async_call( + "media_player", + "play_media", + { + "media_content_id": "1234", + "entity_id": "media_player.bedroom", + "media": { + "media_content_type": "music", + "media_content_id": "1234", + }, + }, + blocking=True, + ) diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index 2b585319826..3fb12b1a90d 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -13,6 +13,7 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, + SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, BrowseMedia, MediaClass, @@ -265,6 +266,88 @@ async def test_volume_media_player_intent(hass: HomeAssistant) -> None: ) +async def test_media_player_mute_intent(hass: HomeAssistant) -> None: + """Test HassMediaPlayerMute intent for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_MUTE} + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) + calls = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_MUTE) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_PLAYER_MUTE, + {}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_VOLUME_MUTE + assert call.data == {"entity_id": entity_id, "is_volume_muted": True} + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_PLAYER_MUTE, + {}, + ) + + +async def test_media_player_unmute_intent(hass: HomeAssistant) -> None: + """Test HassMediaPlayerMute intent for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_MUTE} + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) + calls = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_MUTE) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_PLAYER_UNMUTE, + {}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_VOLUME_MUTE + assert call.data == {"entity_id": entity_id, "is_volume_muted": False} + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_PLAYER_UNMUTE, + {}, + ) + + async def test_multiple_media_players( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/components/media_source/test_helper.py b/tests/components/media_source/test_helper.py new file mode 100644 index 00000000000..54f9e4a19b4 --- /dev/null +++ b/tests/components/media_source/test_helper.py @@ -0,0 +1,129 @@ +"""Test media source helpers.""" + +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components import media_source +from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_source import const, models +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_async_browse_media(hass: HomeAssistant) -> None: + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + # Test non-media ignored (/media has test.mp3 and not_media.txt) + media = await media_source.async_browse_media(hass, "") + assert isinstance(media, media_source.models.BrowseMediaSource) + assert media.title == "media" + assert len(media.children) == 2 + + # Test content filter + media = await media_source.async_browse_media( + hass, + "", + content_filter=lambda item: item.media_content_type.startswith("video/"), + ) + assert isinstance(media, media_source.models.BrowseMediaSource) + assert media.title == "media" + assert len(media.children) == 1, media.children + media.children[0].title = "Epic Sax Guy 10 Hours" + assert media.not_shown == 1 + + # Test content filter adds to original not_shown + orig_browse = models.MediaSourceItem.async_browse + + async def not_shown_browse(self): + """Patch browsed item to set not_shown base value.""" + item = await orig_browse(self) + item.not_shown = 10 + return item + + with patch( + "homeassistant.components.media_source.models.MediaSourceItem.async_browse", + not_shown_browse, + ): + media = await media_source.async_browse_media( + hass, + "", + content_filter=lambda item: item.media_content_type.startswith("video/"), + ) + assert isinstance(media, media_source.models.BrowseMediaSource) + assert media.title == "media" + assert len(media.children) == 1, media.children + media.children[0].title = "Epic Sax Guy 10 Hours" + assert media.not_shown == 11 + + # Test invalid media content + with pytest.raises(BrowseError): + await media_source.async_browse_media(hass, "invalid") + + # Test base URI returns all domains + media = await media_source.async_browse_media(hass, const.URI_SCHEME) + assert isinstance(media, media_source.models.BrowseMediaSource) + assert len(media.children) == 1 + assert media.children[0].title == "My media" + + +async def test_async_resolve_media(hass: HomeAssistant) -> None: + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + media = await media_source.async_resolve_media( + hass, + media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), + None, + ) + assert isinstance(media, media_source.models.PlayMedia) + assert media.url == "/media/local/test.mp3" + assert media.mime_type == "audio/mpeg" + + +async def test_async_resolve_media_no_entity( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + with pytest.raises(RuntimeError): + await media_source.async_resolve_media( + hass, + media_source.generate_media_source_id( + media_source.DOMAIN, "local/test.mp3" + ), + ) + + +async def test_async_unresolve_media(hass: HomeAssistant) -> None: + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + # Test no media content + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media(hass, "", None) + + # Test invalid media content + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media(hass, "invalid", None) + + # Test invalid media source + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media( + hass, "media-source://media_source2", None + ) + + +async def test_browse_resolve_without_setup() -> None: + """Test browse and resolve work without being setup.""" + with pytest.raises(BrowseError): + await media_source.async_browse_media(Mock(data={}), None) + + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media(Mock(data={}), None, None) diff --git a/tests/components/media_source/test_http.py b/tests/components/media_source/test_http.py new file mode 100644 index 00000000000..be69bad753f --- /dev/null +++ b/tests/components/media_source/test_http.py @@ -0,0 +1,127 @@ +"""Test media source HTTP.""" + +from unittest.mock import patch + +import pytest +import yarl + +from homeassistant.components import media_source +from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_source import const +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +async def test_websocket_browse_media( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test browse media websocket.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + media = media_source.models.BrowseMediaSource( + domain=media_source.DOMAIN, + identifier="/media", + title="Local Media", + media_class=MediaClass.DIRECTORY, + media_content_type="listing", + can_play=False, + can_expand=True, + ) + + with patch( + "homeassistant.components.media_source.http.async_browse_media", + return_value=media, + ): + await client.send_json( + { + "id": 1, + "type": "media_source/browse_media", + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["id"] == 1 + assert media.as_dict() == msg["result"] + + with patch( + "homeassistant.components.media_source.http.async_browse_media", + side_effect=BrowseError("test"), + ): + await client.send_json( + { + "id": 2, + "type": "media_source/browse_media", + "media_content_id": "invalid", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "browse_media_failed" + assert msg["error"]["message"] == "test" + + +@pytest.mark.parametrize("filename", ["test.mp3", "Epic Sax Guy 10 Hours.mp4"]) +async def test_websocket_resolve_media( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, filename +) -> None: + """Test browse media websocket.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + media = media_source.models.PlayMedia( + f"/media/local/{filename}", + "audio/mpeg", + ) + + with patch( + "homeassistant.components.media_source.http.async_resolve_media", + return_value=media, + ): + await client.send_json( + { + "id": 1, + "type": "media_source/resolve_media", + "media_content_id": f"{const.URI_SCHEME}{media_source.DOMAIN}/local/{filename}", + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["id"] == 1 + assert msg["result"]["mime_type"] == media.mime_type + + # Validate url is relative and signed. + assert msg["result"]["url"][0] == "/" + parsed = yarl.URL(msg["result"]["url"]) + assert parsed.path == media.url + assert "authSig" in parsed.query + + with patch( + "homeassistant.components.media_source.http.async_resolve_media", + side_effect=media_source.Unresolvable("test"), + ): + await client.send_json( + { + "id": 2, + "type": "media_source/resolve_media", + "media_content_id": "invalid", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "resolve_media_failed" + assert msg["error"]["message"] == "test" diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index 1849fbc09ab..376aa7a4df3 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -1,17 +1,6 @@ """Test Media Source initialization.""" -from unittest.mock import Mock, patch - -import pytest -import yarl - from homeassistant.components import media_source -from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source import const, models -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.typing import WebSocketGenerator async def test_is_media_source_id() -> None: @@ -39,234 +28,3 @@ async def test_generate_media_source_id() -> None: assert media_source.is_media_source_id( media_source.generate_media_source_id(domain, identifier) ) - - -async def test_async_browse_media(hass: HomeAssistant) -> None: - """Test browse media.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - # Test non-media ignored (/media has test.mp3 and not_media.txt) - media = await media_source.async_browse_media(hass, "") - assert isinstance(media, media_source.models.BrowseMediaSource) - assert media.title == "media" - assert len(media.children) == 2 - - # Test content filter - media = await media_source.async_browse_media( - hass, - "", - content_filter=lambda item: item.media_content_type.startswith("video/"), - ) - assert isinstance(media, media_source.models.BrowseMediaSource) - assert media.title == "media" - assert len(media.children) == 1, media.children - media.children[0].title = "Epic Sax Guy 10 Hours" - assert media.not_shown == 1 - - # Test content filter adds to original not_shown - orig_browse = models.MediaSourceItem.async_browse - - async def not_shown_browse(self): - """Patch browsed item to set not_shown base value.""" - item = await orig_browse(self) - item.not_shown = 10 - return item - - with patch( - "homeassistant.components.media_source.models.MediaSourceItem.async_browse", - not_shown_browse, - ): - media = await media_source.async_browse_media( - hass, - "", - content_filter=lambda item: item.media_content_type.startswith("video/"), - ) - assert isinstance(media, media_source.models.BrowseMediaSource) - assert media.title == "media" - assert len(media.children) == 1, media.children - media.children[0].title = "Epic Sax Guy 10 Hours" - assert media.not_shown == 11 - - # Test invalid media content - with pytest.raises(BrowseError): - await media_source.async_browse_media(hass, "invalid") - - # Test base URI returns all domains - media = await media_source.async_browse_media(hass, const.URI_SCHEME) - assert isinstance(media, media_source.models.BrowseMediaSource) - assert len(media.children) == 1 - assert media.children[0].title == "My media" - - -async def test_async_resolve_media(hass: HomeAssistant) -> None: - """Test browse media.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - media = await media_source.async_resolve_media( - hass, - media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), - None, - ) - assert isinstance(media, media_source.models.PlayMedia) - assert media.url == "/media/local/test.mp3" - assert media.mime_type == "audio/mpeg" - - -async def test_async_resolve_media_no_entity( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test browse media.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - with pytest.raises(RuntimeError): - await media_source.async_resolve_media( - hass, - media_source.generate_media_source_id( - media_source.DOMAIN, "local/test.mp3" - ), - ) - - -async def test_async_unresolve_media(hass: HomeAssistant) -> None: - """Test browse media.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - # Test no media content - with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "", None) - - # Test invalid media content - with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "invalid", None) - - # Test invalid media source - with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media( - hass, "media-source://media_source2", None - ) - - -async def test_websocket_browse_media( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test browse media websocket.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - client = await hass_ws_client(hass) - - media = media_source.models.BrowseMediaSource( - domain=media_source.DOMAIN, - identifier="/media", - title="Local Media", - media_class=MediaClass.DIRECTORY, - media_content_type="listing", - can_play=False, - can_expand=True, - ) - - with patch( - "homeassistant.components.media_source.async_browse_media", - return_value=media, - ): - await client.send_json( - { - "id": 1, - "type": "media_source/browse_media", - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["id"] == 1 - assert media.as_dict() == msg["result"] - - with patch( - "homeassistant.components.media_source.async_browse_media", - side_effect=BrowseError("test"), - ): - await client.send_json( - { - "id": 2, - "type": "media_source/browse_media", - "media_content_id": "invalid", - } - ) - - msg = await client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "browse_media_failed" - assert msg["error"]["message"] == "test" - - -@pytest.mark.parametrize("filename", ["test.mp3", "Epic Sax Guy 10 Hours.mp4"]) -async def test_websocket_resolve_media( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, filename -) -> None: - """Test browse media websocket.""" - assert await async_setup_component(hass, media_source.DOMAIN, {}) - await hass.async_block_till_done() - - client = await hass_ws_client(hass) - - media = media_source.models.PlayMedia( - f"/media/local/{filename}", - "audio/mpeg", - ) - - with patch( - "homeassistant.components.media_source.async_resolve_media", - return_value=media, - ): - await client.send_json( - { - "id": 1, - "type": "media_source/resolve_media", - "media_content_id": f"{const.URI_SCHEME}{media_source.DOMAIN}/local/{filename}", - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["id"] == 1 - assert msg["result"]["mime_type"] == media.mime_type - - # Validate url is relative and signed. - assert msg["result"]["url"][0] == "/" - parsed = yarl.URL(msg["result"]["url"]) - assert parsed.path == media.url - assert "authSig" in parsed.query - - with patch( - "homeassistant.components.media_source.async_resolve_media", - side_effect=media_source.Unresolvable("test"), - ): - await client.send_json( - { - "id": 2, - "type": "media_source/resolve_media", - "media_content_id": "invalid", - } - ) - - msg = await client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "resolve_media_failed" - assert msg["error"]["message"] == "test" - - -async def test_browse_resolve_without_setup() -> None: - """Test browse and resolve work without being setup.""" - with pytest.raises(BrowseError): - await media_source.async_browse_media(Mock(data={}), None) - - with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(Mock(data={}), None, None) diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index 259407bfb5a..a4020b5b216 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest from homeassistant.components import media_source, websocket_api +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import const from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config @@ -45,28 +46,28 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Test path not exists - with pytest.raises(media_source.BrowseError) as excinfo: + with pytest.raises(BrowseError) as excinfo: await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/test/not/exist" ) assert str(excinfo.value) == "Path does not exist." # Test browse file - with pytest.raises(media_source.BrowseError) as excinfo: + with pytest.raises(BrowseError) as excinfo: await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/test.mp3" ) assert str(excinfo.value) == "Path is not a directory." # Test invalid base - with pytest.raises(media_source.BrowseError) as excinfo: + with pytest.raises(BrowseError) as excinfo: await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/invalid/base" ) assert str(excinfo.value) == "Unknown source directory." # Test directory traversal - with pytest.raises(media_source.BrowseError) as excinfo: + with pytest.raises(BrowseError) as excinfo: await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/../configuration.yaml" ) @@ -164,19 +165,21 @@ async def test_upload_view( client = await hass_client() # Test normal upload - res = await client.post( - "/api/media_source/local_source/upload", - data={ - "media_content_id": "media-source://media_source/test_dir", - "file": get_file("logo.png"), - }, - ) + with patch.object(Path, "mkdir", autospec=True, return_value=None) as mock_mkdir: + res = await client.post( + "/api/media_source/local_source/upload", + data={ + "media_content_id": "media-source://media_source/test_dir", + "file": get_file("logo.png"), + }, + ) assert res.status == 200 data = await res.json() assert data["media_content_id"] == "media-source://media_source/test_dir/logo.png" uploaded_path = Path(temp_dir) / "logo.png" assert uploaded_path.is_file() + mock_mkdir.assert_called_once() resolved = await media_source.async_resolve_media( hass, data["media_content_id"], target_media_player=None @@ -187,8 +190,6 @@ async def test_upload_view( # Test with bad media source ID for bad_id in ( - # Subdir doesn't exist - "media-source://media_source/test_dir/some-other-dir", # Main dir doesn't exist "media-source://media_source/test_dir2", # Location is invalid @@ -339,7 +340,7 @@ async def test_remove_file( msg = await client.receive_json() - assert not msg["success"] + assert not msg["success"], bad_id assert msg["error"]["code"] == err assert extra_id_file.exists() diff --git a/tests/components/meteo_lt/__init__.py b/tests/components/meteo_lt/__init__.py new file mode 100644 index 00000000000..798b9bd2a79 --- /dev/null +++ b/tests/components/meteo_lt/__init__.py @@ -0,0 +1 @@ +"""Tests for Meteo.lt integration.""" diff --git a/tests/components/meteo_lt/conftest.py b/tests/components/meteo_lt/conftest.py new file mode 100644 index 00000000000..97bfc5c044c --- /dev/null +++ b/tests/components/meteo_lt/conftest.py @@ -0,0 +1,68 @@ +"""Fixtures for Meteo.lt integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from meteo_lt import Forecast, MeteoLtAPI, Place +import pytest + +from homeassistant.components.meteo_lt.const import CONF_PLACE_CODE, DOMAIN + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture(autouse=True) +def mock_meteo_lt_api() -> Generator[AsyncMock]: + """Mock MeteoLtAPI with fixture data.""" + with ( + patch( + "homeassistant.components.meteo_lt.coordinator.MeteoLtAPI", + autospec=True, + ) as mock_api_class, + patch( + "homeassistant.components.meteo_lt.config_flow.MeteoLtAPI", + new=mock_api_class, + ), + ): + mock_api = AsyncMock(spec=MeteoLtAPI) + mock_api_class.return_value = mock_api + + places_data = load_json_array_fixture("places.json", DOMAIN) + forecast_data = load_json_object_fixture("forecast.json", DOMAIN) + + mock_places = [Place.from_dict(place_data) for place_data in places_data] + mock_api.places = mock_places + mock_api.fetch_places.return_value = None + + mock_forecast = Forecast.from_dict(forecast_data) + + mock_api.get_forecast.return_value = mock_forecast + + # Mock get_nearest_place to return Vilnius + mock_api.get_nearest_place.return_value = mock_places[0] + + yield mock_api + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.meteo_lt.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Vilnius", + data={CONF_PLACE_CODE: "vilnius"}, + unique_id="vilnius", + ) diff --git a/tests/components/meteo_lt/fixtures/forecast.json b/tests/components/meteo_lt/fixtures/forecast.json new file mode 100644 index 00000000000..d289adb1394 --- /dev/null +++ b/tests/components/meteo_lt/fixtures/forecast.json @@ -0,0 +1,53 @@ +{ + "place": { + "code": "vilnius", + "name": "Vilnius", + "administrativeDivision": "Vilniaus miesto savivaldyb\u0117", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { "latitude": 54.68705, "longitude": 25.28291 } + }, + "forecastType": "long-term", + "forecastCreationTimeUtc": "2025-09-25 08:01:29", + "forecastTimestamps": [ + { + "forecastTimeUtc": "2025-09-25 10:00:00", + "airTemperature": 10.9, + "feelsLikeTemperature": 10.9, + "windSpeed": 2, + "windGust": 6, + "windDirection": 20, + "cloudCover": 1, + "seaLevelPressure": 1033, + "relativeHumidity": 71, + "totalPrecipitation": 0, + "conditionCode": "clear" + }, + { + "forecastTimeUtc": "2025-09-25 11:00:00", + "airTemperature": 12.2, + "feelsLikeTemperature": 12.2, + "windSpeed": 2, + "windGust": 7, + "windDirection": 25, + "cloudCover": 15, + "seaLevelPressure": 1032, + "relativeHumidity": 68, + "totalPrecipitation": 0, + "conditionCode": "partly-cloudy" + }, + { + "forecastTimeUtc": "2025-09-25 12:00:00", + "airTemperature": 13.5, + "feelsLikeTemperature": 13.5, + "windSpeed": 3, + "windGust": 8, + "windDirection": 30, + "cloudCover": 25, + "seaLevelPressure": 1031, + "relativeHumidity": 65, + "totalPrecipitation": 0.1, + "conditionCode": "cloudy" + } + ] +} diff --git a/tests/components/meteo_lt/fixtures/places.json b/tests/components/meteo_lt/fixtures/places.json new file mode 100644 index 00000000000..b5e2dcb2ca2 --- /dev/null +++ b/tests/components/meteo_lt/fixtures/places.json @@ -0,0 +1,35 @@ +[ + { + "code": "vilnius", + "name": "Vilnius", + "administrativeDivision": "Vilniaus miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 54.68705, + "longitude": 25.28291 + } + }, + { + "code": "kaunas", + "name": "Kaunas", + "administrativeDivision": "Kauno miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 54.90272, + "longitude": 23.95952 + } + }, + { + "code": "klaipeda", + "name": "Klaipėda", + "administrativeDivision": "Klaipėdos miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 55.70329, + "longitude": 21.14427 + } + } +] diff --git a/tests/components/meteo_lt/snapshots/test_weather.ambr b/tests/components/meteo_lt/snapshots/test_weather.ambr new file mode 100644 index 00000000000..a3e5e911530 --- /dev/null +++ b/tests/components/meteo_lt/snapshots/test_weather.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_weather_entity[weather.vilnius-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.vilnius', + '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': 'meteo_lt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'vilnius', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_entity[weather.vilnius-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 10.9, + 'attribution': 'Data provided by Lithuanian Hydrometeorological Service (LHMT)', + 'cloud_coverage': 1, + 'friendly_name': 'Vilnius', + 'humidity': 71, + 'precipitation_unit': , + 'pressure': 1033.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 10.9, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 20, + 'wind_gust_speed': 21.6, + 'wind_speed': 7.2, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.vilnius', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'sunny', + }) +# --- diff --git a/tests/components/meteo_lt/test_config_flow.py b/tests/components/meteo_lt/test_config_flow.py new file mode 100644 index 00000000000..67d9bb934b8 --- /dev/null +++ b/tests/components/meteo_lt/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test the Meteo.lt config flow.""" + +from unittest.mock import AsyncMock + +import aiohttp + +from homeassistant.components.meteo_lt.const import CONF_PLACE_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test user flow shows form and completes successfully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PLACE_CODE: "vilnius"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Vilnius" + assert result["data"] == {CONF_PLACE_CODE: "vilnius"} + assert result["result"].unique_id == "vilnius" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate entry prevention.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PLACE_CODE: "vilnius"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_api_connection_error( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test API connection error during place fetching.""" + mock_meteo_lt_api.places = [] + mock_meteo_lt_api.fetch_places.side_effect = aiohttp.ClientError( + "Connection failed" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_api_timeout_error( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test API timeout error during place fetching.""" + mock_meteo_lt_api.places = [] + mock_meteo_lt_api.fetch_places.side_effect = TimeoutError("Request timed out") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_no_places_found( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test when API returns no places.""" + mock_meteo_lt_api.places = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_places_found" diff --git a/tests/components/meteo_lt/test_weather.py b/tests/components/meteo_lt/test_weather.py new file mode 100644 index 00000000000..27a6c549c03 --- /dev/null +++ b/tests/components/meteo_lt/test_weather.py @@ -0,0 +1,36 @@ +"""Test Meteo.lt weather entity.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import 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) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_lt.PLATFORMS", [Platform.WEATHER]): + yield + + +@pytest.mark.freeze_time("2025-09-25 10:00:00") +async def test_weather_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test weather entity.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/miele/fixtures/action_offline.json b/tests/components/miele/fixtures/action_offline.json new file mode 100644 index 00000000000..e0eb9e14e87 --- /dev/null +++ b/tests/components/miele/fixtures/action_offline.json @@ -0,0 +1,15 @@ +{ + "processAction": [], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [], + "deviceName": false, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/fridge_freezer.json b/tests/components/miele/fixtures/fridge_freezer.json index 8ca28befc35..abda7aeee09 100644 --- a/tests/components/miele/fixtures/fridge_freezer.json +++ b/tests/components/miele/fixtures/fridge_freezer.json @@ -110,5 +110,82 @@ "ecoFeedback": null, "batteryLevel": null } + }, + "DummyAppliance_Fridge_Freezer_Offline": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 21, + "value_localized": "Fridge freezer" + }, + "deviceName": "", + "protocolVersion": 203, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KFN 7734 C", + "matNumber": "12336150", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK037LHBM", + "releaseVersion": "32.33" + } + }, + "state": { + "ProgramID": { + "value_raw": null, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 255, + "value_localized": "Not connected", + "key_localized": "status" + }, + "programType": { + "value_raw": null, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": null, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [], + "startTime": [], + "targetTemperature": [], + "coreTargetTemperature": [], + "temperature": [], + "coreTemperature": [], + "remoteEnable": { + "fullRemoteControl": false, + "smartGrid": false, + "mobileStart": null + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } } } diff --git a/tests/components/miele/snapshots/test_climate.ambr b/tests/components/miele/snapshots/test_climate.ambr index 3b8b7488d9b..1349cf9b2ad 100644 --- a/tests/components/miele/snapshots/test_climate.ambr +++ b/tests/components/miele/snapshots/test_climate.ambr @@ -319,6 +319,70 @@ 'state': 'cool', }) # --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_freezer_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': 'Freezer', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'freezer', + 'unique_id': 'DummyAppliance_Fridge_Freezer_Offline-thermostat2-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Fridge freezer Freezer', + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_freezer_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -383,6 +447,70 @@ 'state': 'cool', }) # --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 9, + 'min_temp': 1, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_refrigerator_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': 'Refrigerator', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'refrigerator', + 'unique_id': 'DummyAppliance_Fridge_Freezer_Offline-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Fridge freezer Refrigerator', + 'hvac_modes': list([ + , + ]), + 'max_temp': 9, + 'min_temp': 1, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_refrigerator_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -447,3 +575,451 @@ 'state': 'cool', }) # --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -15, + 'min_temp': -30, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_zone_3_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': 'Zone 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'zone_3', + 'unique_id': 'DummyAppliance_Fridge_Freezer_Offline-thermostat3-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Fridge freezer Zone 3', + 'hvac_modes': list([ + , + ]), + 'max_temp': -15, + 'min_temp': -30, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_zone_3_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_freezer', + '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': 'Freezer', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'freezer', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat2-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -18, + 'friendly_name': 'Fridge freezer Freezer', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -18, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_freezer_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': 'Freezer', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'freezer', + 'unique_id': 'DummyAppliance_Fridge_Freezer_Offline-thermostat2-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Fridge freezer Freezer', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_freezer_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_refrigerator', + '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': 'Refrigerator', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'refrigerator', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4, + 'friendly_name': 'Fridge freezer Refrigerator', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_refrigerator_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': 'Refrigerator', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'refrigerator', + 'unique_id': 'DummyAppliance_Fridge_Freezer_Offline-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Fridge freezer Refrigerator', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_refrigerator_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_zone_3', + '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': 'Zone 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'zone_3', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat3-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -28, + 'friendly_name': 'Fridge freezer Zone 3', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -25, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_zone_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_zone_3_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': 'Zone 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'zone_3', + 'unique_id': 'DummyAppliance_Fridge_Freezer_Offline-thermostat3-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_offline[fridge_freezer_offline-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Fridge freezer Zone 3', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_zone_3_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index f385a53b6e4..19807bff487 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1595,6 +1595,97 @@ 'state': 'in_use', }) # --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_Fridge_Freezer_Offline-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Fridge freezer', + 'icon': 'mdi:fridge-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_connected', + }) +# --- # name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1651,6 +1742,62 @@ 'state': '4.0', }) # --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_2-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.fridge_freezer_temperature_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_Fridge_Freezer_Offline-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3102,6 +3249,7 @@ 'energy_save', 'heating_up', 'not_running', + 'pre_heating', 'process_finished', 'process_running', ]), @@ -3144,6 +3292,7 @@ 'energy_save', 'heating_up', 'not_running', + 'pre_heating', 'process_finished', 'process_running', ]), @@ -3270,7 +3419,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.oven_start_in-entry] @@ -3326,7 +3475,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.oven_target_temperature-entry] @@ -3757,7 +3906,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -3785,7 +3934,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.washing_machine_energy_forecast-entry] @@ -4165,7 +4314,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.washing_machine_spin_speed-entry] @@ -4270,7 +4419,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.washing_machine_target_temperature-entry] @@ -4354,7 +4503,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -4382,7 +4531,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.washing_machine_water_forecast-entry] @@ -5248,6 +5397,7 @@ 'energy_save', 'heating_up', 'not_running', + 'pre_heating', 'process_finished', 'process_running', ]), @@ -5290,6 +5440,7 @@ 'energy_save', 'heating_up', 'not_running', + 'pre_heating', 'process_finished', 'process_running', ]), @@ -5903,7 +6054,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -5931,7 +6082,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_energy_forecast-entry] @@ -6311,7 +6462,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_spin_speed-entry] @@ -6416,7 +6567,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_temperature-entry] @@ -6500,7 +6651,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -6528,7 +6679,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_forecast-entry] @@ -6952,6 +7103,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- diff --git a/tests/components/miele/test_climate.py b/tests/components/miele/test_climate.py index 392a6712707..6cbae344a41 100644 --- a/tests/components/miele/test_climate.py +++ b/tests/components/miele/test_climate.py @@ -34,6 +34,22 @@ async def test_climate_states( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("load_device_file", ["fridge_freezer.json"]) +@pytest.mark.parametrize( + "load_action_file", ["action_offline.json"], ids=["fridge_freezer_offline"] +) +async def test_climate_states_offline( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test climate entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + @pytest.mark.parametrize("load_device_file", ["fridge_freezer.json"]) @pytest.mark.parametrize( "load_action_file", ["action_fridge_freezer.json"], ids=["fridge_freezer"] diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py index cdf1a39b421..0448096a115 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -5,7 +5,7 @@ import http import time from unittest.mock import MagicMock -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from freezegun.api import FrozenDateTimeFactory from pymiele import OAUTH2_TOKEN import pytest @@ -210,3 +210,29 @@ async def test_setup_all_platforms( # Check a sample sensor for each new device assert hass.states.get("sensor.dishwasher").state == "in_use" assert hass.states.get("sensor.oven_temperature_2").state == "175.0" + + +@pytest.mark.parametrize( + "side_effect", + [ + ClientResponseError("test", "Test"), + TimeoutError, + ], + ids=[ + "ClientResponseError", + "TimeoutError", + ], +) +async def test_load_entry_with_action_error( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test load with error from actions endpoint.""" + mock_miele_client.get_actions.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + assert mock_miele_client.get_actions.call_count == 5 diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index f8d620c8bd0..d8c054683fa 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -311,9 +311,17 @@ async def test_laundry_wash_scenario( hass, "sensor.washing_machine_target_temperature", "unknown", step ) check_sensor_state(hass, "sensor.washing_machine_spin_speed", "unknown", step) - check_sensor_state(hass, "sensor.washing_machine_remaining_time", "0", step) + # OFF -> remaining forced to unknown + check_sensor_state(hass, "sensor.washing_machine_remaining_time", "unknown", step) # OFF -> elapsed forced to unknown (some devices continue reporting last value of last cycle) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "unknown", step) + # consumption sensors have to report "unknown" when the device is not working + check_sensor_state( + hass, "sensor.washing_machine_energy_consumption", "unknown", step + ) + check_sensor_state( + hass, "sensor.washing_machine_water_consumption", "unknown", step + ) # Simulate program started device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 5 @@ -336,10 +344,41 @@ async def test_laundry_wash_scenario( device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 12 device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_raw"] = 1200 device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_localized"] = "1200" + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.9, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 52, + "unit": "l", + }, + } freezer.tick(timedelta(seconds=130)) async_fire_time_changed(hass) await hass.async_block_till_done() + + # at this point, appliance is working, but it started reporting a value from last cycle, so it is forced to 0 + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step) + + # intermediate step, only to report new consumption values + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.0, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 0, + "unit": "l", + }, + } + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + step += 1 check_sensor_state(hass, "sensor.washing_machine", "in_use", step) @@ -347,9 +386,31 @@ async def test_laundry_wash_scenario( check_sensor_state(hass, "sensor.washing_machine_program_phase", "main_wash", step) check_sensor_state(hass, "sensor.washing_machine_target_temperature", "30.0", step) check_sensor_state(hass, "sensor.washing_machine_spin_speed", "1200", step) + # IN_USE -> elapsed, remaining time from API (normal case) check_sensor_state(hass, "sensor.washing_machine_remaining_time", "105", step) - # IN_USE -> elapsed time from API (normal case) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "12", step) + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.0", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step) + + # intermediate step, only to report new consumption values + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.1, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 7, + "unit": "l", + }, + } + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # at this point, it starts reporting value from API + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step) # Simulate rinse hold phase device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 11 @@ -373,8 +434,8 @@ async def test_laundry_wash_scenario( check_sensor_state(hass, "sensor.washing_machine_program_phase", "rinse_hold", step) check_sensor_state(hass, "sensor.washing_machine_target_temperature", "30.0", step) check_sensor_state(hass, "sensor.washing_machine_spin_speed", "1200", step) + # RINSE HOLD -> elapsed, remaining time from API (normal case) check_sensor_state(hass, "sensor.washing_machine_remaining_time", "8", step) - # RINSE HOLD -> elapsed time from API (normal case) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "109", step) # Simulate program ended @@ -388,6 +449,7 @@ async def test_laundry_wash_scenario( device_fixture["DummyWasher"]["state"]["remainingTime"][1] = 0 device_fixture["DummyWasher"]["state"]["elapsedTime"][0] = 0 device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 0 + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = None freezer.tick(timedelta(seconds=130)) async_fire_time_changed(hass) @@ -401,9 +463,13 @@ async def test_laundry_wash_scenario( ) check_sensor_state(hass, "sensor.washing_machine_target_temperature", "30.0", step) check_sensor_state(hass, "sensor.washing_machine_spin_speed", "1200", step) + # PROGRAM_ENDED -> remaining time forced to 0 check_sensor_state(hass, "sensor.washing_machine_remaining_time", "0", step) # PROGRAM_ENDED -> elapsed time kept from last program (some devices immediately go to 0) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "109", step) + # consumption values now are reporting last known value, API might start reporting null object + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step) # Simulate when door is opened after program ended device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 3 @@ -433,8 +499,8 @@ async def test_laundry_wash_scenario( ) check_sensor_state(hass, "sensor.washing_machine_target_temperature", "40.0", step) check_sensor_state(hass, "sensor.washing_machine_spin_speed", "1200", step) + # PROGRAMMED -> elapsed, remaining time from API (normal case) check_sensor_state(hass, "sensor.washing_machine_remaining_time", "119", step) - # PROGRAMMED -> elapsed time from API (normal case) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "0", step) @@ -457,8 +523,8 @@ async def test_laundry_dry_scenario( check_sensor_state(hass, "sensor.tumble_dryer_program", "no_program", step) check_sensor_state(hass, "sensor.tumble_dryer_program_phase", "not_running", step) check_sensor_state(hass, "sensor.tumble_dryer_drying_step", "unknown", step) - check_sensor_state(hass, "sensor.tumble_dryer_remaining_time", "0", step) - # OFF -> elapsed forced to unknown (some devices continue reporting last value of last cycle) + # OFF -> elapsed, remaining forced to unknown (some devices continue reporting last value of last cycle) + check_sensor_state(hass, "sensor.tumble_dryer_remaining_time", "unknown", step) check_sensor_state(hass, "sensor.tumble_dryer_elapsed_time", "unknown", step) # Simulate program started @@ -486,8 +552,8 @@ async def test_laundry_dry_scenario( check_sensor_state(hass, "sensor.tumble_dryer_program", "minimum_iron", step) check_sensor_state(hass, "sensor.tumble_dryer_program_phase", "drying", step) check_sensor_state(hass, "sensor.tumble_dryer_drying_step", "normal", step) + # IN_USE -> elapsed, remaining time from API (normal case) check_sensor_state(hass, "sensor.tumble_dryer_remaining_time", "49", step) - # IN_USE -> elapsed time from API (normal case) check_sensor_state(hass, "sensor.tumble_dryer_elapsed_time", "20", step) # Simulate program end @@ -511,6 +577,7 @@ async def test_laundry_dry_scenario( check_sensor_state(hass, "sensor.tumble_dryer_program", "minimum_iron", step) check_sensor_state(hass, "sensor.tumble_dryer_program_phase", "finished", step) check_sensor_state(hass, "sensor.tumble_dryer_drying_step", "normal", step) + # PROGRAM_ENDED -> remaining time forced to 0 check_sensor_state(hass, "sensor.tumble_dryer_remaining_time", "0", step) # PROGRAM_ENDED -> elapsed time kept from last program (some devices immediately go to 0) check_sensor_state(hass, "sensor.tumble_dryer_elapsed_time", "20", step) diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index a7a70043d94..c7f96e3aa2a 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant import config as hass_config from homeassistant.components.min_max.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, SERVICE_RELOAD, @@ -59,6 +60,7 @@ async def test_default_name_sensor(hass: HomeAssistant) -> None: assert str(float(MIN_VALUE)) == state.state assert entity_ids[2] == state.attributes.get("min_entity_id") + assert state.attributes.get(ATTR_ENTITY_ID) == entity_ids async def test_min_sensor( diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index f7bd4b13a1b..a57c2cfdcc5 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -11,7 +11,7 @@ from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest -from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP +from homeassistant.components.modbus.const import DOMAIN, TCP from homeassistant.const import ( CONF_ADDRESS, CONF_HOST, diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index e1c0e08a113..a8acb5f4674 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -237,6 +237,8 @@ async def test_service_binary_sensor_update( ENTITY_ID2 = f"{ENTITY_ID}_1" +# The new update secures the sensors are read at startup, so restore_state delivers old data. +@pytest.mark.skip @pytest.mark.parametrize( "mock_test_state", [ @@ -437,7 +439,7 @@ async def test_no_discovery_info_binary_sensor( assert await async_setup_component( hass, SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index f661dd2083c..14bc46042f6 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -84,7 +84,7 @@ from homeassistant.components.modbus.const import ( CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, - MODBUS_DOMAIN, + DOMAIN, DataType, ) from homeassistant.const import ( @@ -1616,6 +1616,11 @@ test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} +# Due to fact that modbus now reads imidiatly after connect and the +# fixture do not return until connected, it is not possible to +# test the restore. +# THIS IS WORK TBD. +@pytest.mark.skip @pytest.mark.parametrize( "mock_test_state", [(test_value,)], @@ -1690,7 +1695,7 @@ async def test_no_discovery_info_climate( assert await async_setup_component( hass, CLIMATE_DOMAIN, - {CLIMATE_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {CLIMATE_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert CLIMATE_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index ae709f483e1..9f3a64c27e5 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -16,7 +16,7 @@ from homeassistant.components.modbus.const import ( CONF_STATE_OPENING, CONF_STATUS_REGISTER, CONF_STATUS_REGISTER_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -202,6 +202,11 @@ async def test_service_cover_update(hass: HomeAssistant, mock_modbus_ha) -> None assert hass.states.get(ENTITY_ID).state == CoverState.OPEN +# Due to fact that modbus now reads imidiatly after connect and the +# fixture do not return until connected, it is not possible to +# test the restore. +# THIS IS WORK TBD. +@pytest.mark.skip @pytest.mark.parametrize( "mock_test_state", [ @@ -300,7 +305,7 @@ async def test_no_discovery_info_cover( assert await async_setup_component( hass, COVER_DOMAIN, - {COVER_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {COVER_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert COVER_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 2afc6314048..c9796bbaf3c 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -17,7 +17,7 @@ from homeassistant.components.modbus.const import ( CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -270,7 +270,7 @@ async def test_fan_service_turn( ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( @@ -354,7 +354,7 @@ async def test_no_discovery_info_fan( assert await async_setup_component( hass, FAN_DOMAIN, - {FAN_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {FAN_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert FAN_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 3816e9878cb..a0c38e37ce5 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -8,7 +8,7 @@ This file is responsible for testing: const.py modbus.py validators.py - baseplatform.py (only BasePlatform) + entity.py (only ModbusBaseEntity) It uses binary_sensors/sensors to do black box testing of the read calls. """ @@ -64,7 +64,7 @@ from homeassistant.components.modbus.const import ( CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, DEVICE_ID, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, RTUOVERTCP, SERIAL, SERVICE_STOP, @@ -107,6 +107,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -696,7 +697,7 @@ async def test_no_duplicate_names(hass: HomeAssistant, do_config) -> None: }, { CONF_TYPE: TCP, - CONF_HOST: TEST_MODBUS_HOST, + CONF_HOST: TEST_MODBUS_HOST + "_1", CONF_PORT: TEST_PORT_TCP, CONF_NAME: f"{TEST_MODBUS_NAME} 2", CONF_SENSORS: [ @@ -723,6 +724,32 @@ async def test_no_duplicate_names(hass: HomeAssistant, do_config) -> None: ], }, ], + [ + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: TEST_MODBUS_NAME, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP + 10, + CONF_NAME: f"{TEST_MODBUS_NAME} 2", + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + ], { # Special test for scan_interval validator with scan_interval: 0 CONF_TYPE: TCP, @@ -753,10 +780,116 @@ async def test_no_duplicate_names(hass: HomeAssistant, do_config) -> None: }, ], ) -async def test_config_modbus( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus_with_pymodbus +async def test_config_modbus(hass: HomeAssistant, mock_modbus_with_pymodbus) -> None: + """Run configuration test for modbus.""" + assert len(hass.data[DOMAIN]) + + +@pytest.mark.parametrize( + "do_config", + [ + [ + # Duplicate CONF_NAME + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: TEST_MODBUS_NAME, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: SERIAL, + CONF_BAUDRATE: 9600, + CONF_BYTESIZE: 8, + CONF_METHOD: "rtu", + CONF_PORT: TEST_PORT_SERIAL, + CONF_PARITY: "E", + CONF_STOPBITS: 1, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + ], + [ + # Duplicate CONF_HOST+CONF_PORT (for type != SERIAL) + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: TEST_MODBUS_NAME, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: TEST_MODBUS_NAME + "_1", + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + ], + [ + # Duplicate CONF_PORT (for type == SERIAL) + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: SERIAL, + CONF_BAUDRATE: 9600, + CONF_BYTESIZE: 8, + CONF_METHOD: "rtu", + CONF_PORT: TEST_PORT_SERIAL, + CONF_PARITY: "E", + CONF_STOPBITS: 1, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + { + CONF_NAME: TEST_MODBUS_NAME + "_1", + CONF_TYPE: SERIAL, + CONF_BAUDRATE: 9600, + CONF_BYTESIZE: 8, + CONF_METHOD: "rtu", + CONF_PORT: TEST_PORT_SERIAL, + CONF_PARITY: "E", + CONF_STOPBITS: 1, + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 9999, + } + ], + }, + ], + ], +) +async def test_config_wrong_modbus( + hass: HomeAssistant, mock_modbus_with_pymodbus, issue_registry: ir.IssueRegistry ) -> None: """Run configuration test for modbus.""" + assert len(hass.data[DOMAIN]) == 1 + assert len(issue_registry.issues) == 1 + assert (DOMAIN, "duplicate_modbus_entry") in issue_registry.issues VALUE = "value" diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 56b6d0ef3b4..9b8eed7437f 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -22,7 +22,7 @@ from homeassistant.components.modbus.const import ( CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -311,7 +311,7 @@ async def test_light_service_turn( ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID} @@ -535,7 +535,7 @@ async def test_no_discovery_info_light( assert await async_setup_component( hass, LIGHT_DOMAIN, - {LIGHT_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {LIGHT_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert LIGHT_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 4910b4df065..ef9c6b5b8cd 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, CONF_ZERO_SUPPRESS, - MODBUS_DOMAIN, + DOMAIN, DataType, ) from homeassistant.components.sensor import ( @@ -1357,6 +1357,46 @@ async def test_wrap_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None assert hass.states.get(ENTITY_ID).state == expected +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 201, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + ("config_addon", "register_words", "expected"), + [ + ( + { + CONF_SWAP: CONF_SWAP_WORD, + CONF_DATA_TYPE: DataType.UINT32, + }, + [0x0102, 0x0304], + "50594050", + ), + ], +) +async def test_wrap_regs_ok_sensor( + hass: HomeAssistant, mock_modbus_ha, mock_do_cycle, expected +) -> None: + """Run test for sensor struct.""" + assert hass.states.get(ENTITY_ID).state == expected + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).state == expected + + @pytest.fixture(name="mock_restore") async def mock_restore(hass: HomeAssistant) -> None: """Mock restore cache.""" @@ -1442,7 +1482,7 @@ async def test_no_discovery_info_sensor( assert await async_setup_component( hass, SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index fc994c70d49..f9763e80307 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -19,7 +19,7 @@ from homeassistant.components.modbus.const import ( CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -349,7 +349,7 @@ async def test_switch_service_turn( mock_modbus, ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( @@ -520,7 +520,7 @@ async def test_no_discovery_info_switch( assert await async_setup_component( hass, SWITCH_DOMAIN, - {SWITCH_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SWITCH_DOMAIN in hass.config.components diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index fdaed0c323f..a45ea4c0648 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -70,6 +70,78 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { "configuration_url": "http://example.com", } +MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_LOCAL_CODE = { + "4b06357ef8654e8d9c54cee5bb0e9391": { + "platform": "alarm_control_panel", + "name": "Alarm", + "entity_category": "config", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code": "1234", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + "supported_features": ["arm_home", "arm_away", "trigger"], + "retain": False, + "entity_picture": "https://example.com/4b06357ef8654e8d9c54cee5bb0e9391", + }, +} +MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_REMOTE_CODE = { + "4b06357ef8654e8d9c54cee5bb0e9392": { + "platform": "alarm_control_panel", + "name": "Alarm", + "entity_category": None, + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code": "REMOTE_CODE", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + "supported_features": ["arm_home", "arm_away", "arm_custom_bypass"], + "retain": False, + "entity_picture": "https://example.com/4b06357ef8654e8d9c54cee5bb0e9392", + }, +} +MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_REMOTE_CODE_TEXT = { + "4b06357ef8654e8d9c54cee5bb0e9393": { + "platform": "alarm_control_panel", + "name": "Alarm", + "entity_category": None, + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code": "REMOTE_CODE_TEXT", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + "supported_features": ["arm_home", "arm_away", "arm_vacation"], + "retain": False, + "entity_picture": "https://example.com/4b06357ef8654e8d9c54cee5bb0e9393", + }, +} MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT = { "5b06357ef8654e8d9c54cee5bb0e939b": { "platform": "binary_sensor", @@ -284,6 +356,72 @@ MOCK_SUBENTRY_FAN_COMPONENT = { "speed_range_min": 1, }, } +MOCK_SUBENTRY_IMAGE_COMPONENT_DATA = { + "24402bcbd5b64a54bc32695a5ef752bf": { + "platform": "image", + "name": "Merchandise", + "entity_category": None, + "image_topic": "test-topic", + "content_type": "image/jpeg", + "image_encoding": "b64", + "entity_picture": "https://example.com/24402bcbd5b64a54bc32695a5ef752bf", + }, +} +MOCK_SUBENTRY_IMAGE_COMPONENT_URL = { + "326104eb58af48c9ab1f887cded499bb": { + "platform": "image", + "name": "Merchandise", + "entity_category": None, + "url_topic": "test-topic", + "url_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/326104eb58af48c9ab1f887cded499bb", + }, +} +MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { + "8131babc5e8d4f44b82e0761d39091a2": { + "platform": "light", + "name": "Basic light", + "on_command_type": "last", + "optimistic": True, + "payload_off": "OFF", + "payload_on": "ON", + "command_topic": "test-topic", + "entity_category": None, + "schema": "basic", + "state_topic": "test-topic", + "color_temp_kelvin": True, + "state_value_template": "{{ value_json.value }}", + "brightness_scale": 255, + "max_kelvin": 6535, + "min_kelvin": 2000, + "white_scale": 255, + "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", + }, +} +MOCK_SUBENTRY_LOCK_COMPONENT = { + "3faf1318016c46c5aea26707eeb6f100": { + "platform": "lock", + "name": "Lock", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "code_format": "^\\d{4}$", + "payload_open": "OPEN", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_reset": "None", + "state_jammed": "JAMMED", + "state_locked": "LOCKED", + "state_locking": "LOCKING", + "state_unlocked": "UNLOCKED", + "state_unlocking": "UNLOCKING", + "retain": False, + "entity_category": None, + "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f100", + "optimistic": True, + }, +} MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "363a7ecad6be4a19b939a016ea93e994": { "platform": "notify", @@ -315,7 +453,13 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { "retain": False, }, } - +MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { + "b10b531e15244425a74bb0abb1e9d2c6": { + "platform": "notify", + "name": "Test", + "command_topic": "bad#topic", + }, +} MOCK_SUBENTRY_SENSOR_COMPONENT = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", @@ -367,35 +511,6 @@ MOCK_SUBENTRY_SWITCH_COMPONENT = { }, } -MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { - "8131babc5e8d4f44b82e0761d39091a2": { - "platform": "light", - "name": "Basic light", - "on_command_type": "last", - "optimistic": True, - "payload_off": "OFF", - "payload_on": "ON", - "command_topic": "test-topic", - "entity_category": None, - "schema": "basic", - "state_topic": "test-topic", - "color_temp_kelvin": True, - "state_value_template": "{{ value_json.value }}", - "brightness_scale": 255, - "max_kelvin": 6535, - "min_kelvin": 2000, - "white_scale": 255, - "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", - }, -} -MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { - "b10b531e15244425a74bb0abb1e9d2c6": { - "platform": "notify", - "name": "Test", - "command_topic": "bad#topic", - }, -} - MOCK_SUBENTRY_AVAILABILITY_DATA = { "availability": { "availability_topic": "test/availability", @@ -411,6 +526,7 @@ MOCK_SUBENTRY_DEVICE_DATA = { "hw_version": "2.1 rev a", "model": "Model XL", "model_id": "mn002", + "manufacturer": "Milk Masters", "configuration_url": "https://example.com", } @@ -419,6 +535,18 @@ MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, } | MOCK_SUBENTRY_AVAILABILITY_DATA +MOCK_ALARM_CONTROL_PANEL_LOCAL_CODE_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_LOCAL_CODE, +} +MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_TEXT_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_REMOTE_CODE_TEXT, +} +MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_REMOTE_CODE, +} MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, "components": MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT, @@ -447,6 +575,22 @@ MOCK_FAN_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_FAN_COMPONENT, } +MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_IMAGE_COMPONENT_DATA, +} +MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_IMAGE_COMPONENT_URL, +} +MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, +} +MOCK_LOCK_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_LOCK_COMPONENT, +} MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, @@ -455,10 +599,6 @@ MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, } -MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { - "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, - "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, -} MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT, diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index f99c48a440f..571308f0158 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -55,7 +55,7 @@ DEFAULT_CONFIG = { button.DOMAIN: { "command_topic": "command-topic", "name": "test", - "object_id": "test_button", + "default_entity_id": "button.test_button", "payload_press": "beer press", "qos": "2", } diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 3b4f090aef3..e94e842b7c3 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -33,6 +33,9 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( + MOCK_ALARM_CONTROL_PANEL_LOCAL_CODE_SUBENTRY_DATA_SINGLE, + MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_SUBENTRY_DATA_SINGLE, + MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_TEXT_SUBENTRY_DATA_SINGLE, MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_BUTTON_SUBENTRY_DATA_SINGLE, MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, @@ -40,7 +43,10 @@ from .common import ( MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, MOCK_COVER_SUBENTRY_DATA_SINGLE, MOCK_FAN_SUBENTRY_DATA_SINGLE, + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA, + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL, MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + MOCK_LOCK_SUBENTRY_DATA_SINGLE, MOCK_NOTIFY_SUBENTRY_DATA_MULTI, MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, @@ -2664,7 +2670,117 @@ async def test_migrate_of_incompatible_config_entry( "entity_name", ), [ - ( + pytest.param( + MOCK_ALARM_CONTROL_PANEL_LOCAL_CODE_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Alarm"}, + { + "entity_category": "config", + "supported_features": ["arm_home", "arm_away", "trigger"], + "alarm_control_panel_code_mode": "local_code", + }, + (), + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code": "1234", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "retain": False, + "alarm_control_panel_payload_settings": { + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + }, + }, + ( + ( + { + "state_topic": "test-topic", + "command_topic": "test-topic#invalid", + }, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Alarm", + id="alarm_control_panel_local_code", + ), + pytest.param( + MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, + {"name": "Alarm"}, + { + "supported_features": ["arm_home", "arm_away", "arm_custom_bypass"], + "alarm_control_panel_code_mode": "remote_code", + }, + (), + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "retain": False, + "alarm_control_panel_payload_settings": { + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + }, + }, + (), + "Milk notifier Alarm", + id="alarm_control_panel_remote_code", + ), + pytest.param( + MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_TEXT_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, + {"name": "Alarm"}, + { + "supported_features": ["arm_home", "arm_away", "arm_vacation"], + "alarm_control_panel_code_mode": "remote_code_text", + }, + (), + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "retain": False, + "alarm_control_panel_payload_settings": { + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + }, + }, + (), + "Milk notifier Alarm", + id="alarm_control_panel_remote_code_text", + ), + pytest.param( MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, {"name": "Hatch"}, @@ -2682,8 +2798,9 @@ async def test_migrate_of_incompatible_config_entry( ), ), "Milk notifier Hatch", + id="binary_sensor", ), - ( + pytest.param( MOCK_BUTTON_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, {"name": "Restart"}, @@ -2702,8 +2819,83 @@ async def test_migrate_of_incompatible_config_entry( ), ), "Milk notifier Restart", + id="button", ), - ( + pytest.param( + MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Cooler"}, + { + "temperature_unit": "C", + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "high_low", + "climate_feature_target_humidity": False, + }, + (), + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # high/low target temperature + "target_temperature_settings": { + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + }, + (), + "Milk notifier Cooler", + id="climate_high_low", + ), + pytest.param( + MOCK_CLIMATE_NO_TARGET_TEMP_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Cooler"}, + { + "temperature_unit": "C", + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "none", + "climate_feature_target_humidity": False, + }, + (), + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + }, + (), + "Milk notifier Cooler", + id="climate_no_target_temp", + ), + pytest.param( MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Cooler"}, @@ -2848,80 +3040,9 @@ async def test_migrate_of_incompatible_config_entry( ), ), "Milk notifier Cooler", + id="climate_single", ), - ( - MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, - {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, - {"name": "Cooler"}, - { - "temperature_unit": "C", - "climate_feature_action": False, - "climate_feature_current_humidity": False, - "climate_feature_current_temperature": False, - "climate_feature_power": False, - "climate_feature_preset_modes": False, - "climate_feature_fan_modes": False, - "climate_feature_swing_horizontal_modes": False, - "climate_feature_swing_modes": False, - "climate_feature_target_temperature": "high_low", - "climate_feature_target_humidity": False, - }, - (), - { - "mode_command_topic": "mode-command-topic", - "mode_command_template": "{{ value }}", - "mode_state_topic": "mode-state-topic", - "mode_state_template": "{{ value_json.mode }}", - "modes": ["off", "heat", "cool", "auto"], - # high/low target temperature - "target_temperature_settings": { - "temperature_low_command_topic": "temperature-low-command-topic", - "temperature_low_command_template": "{{ value }}", - "temperature_low_state_topic": "temperature-low-state-topic", - "temperature_low_state_template": "{{ value_json.temperature_low }}", - "temperature_high_command_topic": "temperature-high-command-topic", - "temperature_high_command_template": "{{ value }}", - "temperature_high_state_topic": "temperature-high-state-topic", - "temperature_high_state_template": "{{ value_json.temperature_high }}", - "min_temp": 8, - "max_temp": 28, - "precision": "0.1", - "temp_step": 1.0, - "initial": 19.0, - }, - }, - (), - "Milk notifier Cooler", - ), - ( - MOCK_CLIMATE_NO_TARGET_TEMP_SUBENTRY_DATA_SINGLE, - {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, - {"name": "Cooler"}, - { - "temperature_unit": "C", - "climate_feature_action": False, - "climate_feature_current_humidity": False, - "climate_feature_current_temperature": False, - "climate_feature_power": False, - "climate_feature_preset_modes": False, - "climate_feature_fan_modes": False, - "climate_feature_swing_horizontal_modes": False, - "climate_feature_swing_modes": False, - "climate_feature_target_temperature": "none", - "climate_feature_target_humidity": False, - }, - (), - { - "mode_command_topic": "mode-command-topic", - "mode_command_template": "{{ value }}", - "mode_state_topic": "mode-state-topic", - "mode_state_template": "{{ value_json.mode }}", - "modes": ["off", "heat", "cool", "auto"], - }, - (), - "Milk notifier Cooler", - ), - ( + pytest.param( MOCK_COVER_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Blind"}, @@ -3006,8 +3127,9 @@ async def test_migrate_of_incompatible_config_entry( ), ), "Milk notifier Blind", + id="cover", ), - ( + pytest.param( MOCK_FAN_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Breezer"}, @@ -3157,27 +3279,143 @@ async def test_migrate_of_incompatible_config_entry( ), ), "Milk notifier Breezer", + id="fan", ), - ( - MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, - {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, - {"name": "Milkman alert"}, - {}, + pytest.param( + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_DATA, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Merchandise"}, + {"image_processing_mode": "image_data"}, (), + { + "image_topic": "test-topic", + "content_type": "image/jpeg", + "image_encoding": "b64", + }, + ( + ( + {"image_topic": "test-topic#invalid", "content_type": "image/jpeg"}, + {"image_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Merchandise", + id="notify_image_data", + ), + pytest.param( + MOCK_IMAGE_SUBENTRY_DATA_IMAGE_URL, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Merchandise"}, + {"image_processing_mode": "image_url"}, + (), + { + "url_topic": "test-topic", + "url_template": "{{ value_json.value }}", + }, + ( + ( + {"url_topic": "test-topic#invalid"}, + {"url_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Merchandise", + id="notify_image_url", + ), + pytest.param( + MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, + {"name": "Basic light"}, + {}, + {}, { "command_topic": "test-topic", - "command_template": "{{ value }}", - "retain": False, + "state_topic": "test-topic", + "state_value_template": "{{ value_json.value }}", + "optimistic": True, }, ( ( {"command_topic": "test-topic#invalid"}, {"command_topic": "invalid_publish_topic"}, ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ( + { + "command_topic": "test-topic", + "light_brightness_settings": { + "brightness_command_topic": "test-topic#invalid" + }, + }, + {"light_brightness_settings": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000}, + }, + { + "advanced_settings": "max_below_min_kelvin", + }, + ), ), - "Milk notifier Milkman alert", + "Milk notifier Basic light", + id="light_basic_kelvin", ), - ( + pytest.param( + MOCK_LOCK_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Lock"}, + {}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "code_format": "^\\d{4}$", + "optimistic": True, + "retain": False, + "lock_payload_settings": { + "payload_open": "OPEN", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_reset": "None", + "state_jammed": "JAMMED", + "state_locked": "LOCKED", + "state_locking": "LOCKING", + "state_unlocked": "UNLOCKED", + "state_unlocking": "UNLOCKING", + }, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ( + { + "command_topic": "test-topic", + "code_format": "(", + }, + {"code_format": "invalid_regular_expression"}, + ), + ), + "Milk notifier Lock", + id="lock", + ), + pytest.param( MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {}, @@ -3195,8 +3433,29 @@ async def test_migrate_of_incompatible_config_entry( ), ), "Milk notifier", + id="notify_no_entity_name", ), - ( + pytest.param( + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, + {"name": "Milkman alert"}, + {}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "retain": False, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), + "Milk notifier Milkman alert", + id="notify_with_entity_name", + ), + pytest.param( MOCK_SENSOR_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Energy"}, @@ -3251,8 +3510,9 @@ async def test_migrate_of_incompatible_config_entry( ), ), "Milk notifier Energy", + id="sensor_options", ), - ( + pytest.param( MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Energy"}, @@ -3273,8 +3533,9 @@ async def test_migrate_of_incompatible_config_entry( }, (), "Milk notifier Energy", + id="sensor_total", ), - ( + pytest.param( MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Outlet"}, @@ -3301,67 +3562,8 @@ async def test_migrate_of_incompatible_config_entry( ), ), "Milk notifier Outlet", + id="switch", ), - ( - MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, - {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, - {"name": "Basic light"}, - {}, - {}, - { - "command_topic": "test-topic", - "state_topic": "test-topic", - "state_value_template": "{{ value_json.value }}", - "optimistic": True, - }, - ( - ( - {"command_topic": "test-topic#invalid"}, - {"command_topic": "invalid_publish_topic"}, - ), - ( - { - "command_topic": "test-topic", - "state_topic": "test-topic#invalid", - }, - {"state_topic": "invalid_subscribe_topic"}, - ), - ( - { - "command_topic": "test-topic", - "light_brightness_settings": { - "brightness_command_topic": "test-topic#invalid" - }, - }, - {"light_brightness_settings": "invalid_publish_topic"}, - ), - ( - { - "command_topic": "test-topic", - "advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000}, - }, - { - "advanced_settings": "max_below_min_kelvin", - }, - ), - ), - "Milk notifier Basic light", - ), - ], - ids=[ - "binary_sensor", - "button", - "climate_single", - "climate_high_low", - "climate_no_target_temp", - "cover", - "fan", - "notify_with_entity_name", - "notify_no_entity_name", - "sensor_options", - "sensor_total", - "switch", - "light_basic_kelvin", ], ) async def test_subentry_configflow( @@ -3779,93 +3981,118 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "removed_options", ), [ - ( + pytest.param( ( ConfigSubentryData( - data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + data=MOCK_ALARM_CONTROL_PANEL_LOCAL_CODE_SUBENTRY_DATA_SINGLE, subentry_type="device", title="Mock subentry", ), ), (), - {}, + { + "alarm_control_panel_code_mode": "remote_code", + "supported_features": ["arm_home", "arm_away", "arm_custom_bypass"], + }, { "command_topic": "test-topic1-updated", "command_template": "{{ value }}", + "state_topic": "test-topic1-updated", + "value_template": "{{ value }}", "retain": True, }, { "command_topic": "test-topic1-updated", "command_template": "{{ value }}", + "state_topic": "test-topic1-updated", + "value_template": "{{ value }}", + "retain": True, + "code": "REMOTE_CODE", + }, + {"entity_picture"}, + id="alarm_control_panel_local_code", + ), + pytest.param( + ( + ConfigSubentryData( + data=MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), + { + "alarm_control_panel_code_mode": "local_code", + "supported_features": ["arm_home", "arm_away", "arm_custom_bypass"], + }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "state_topic": "test-topic1-updated", + "value_template": "{{ value }}", + "code": "1234", + "retain": True, + }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "state_topic": "test-topic1-updated", + "value_template": "{{ value }}", + "code": "1234", "retain": True, }, {"entity_picture"}, + id="alarm_control_panel_remote_code", ), - ( + pytest.param( ( ConfigSubentryData( - data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE, - subentry_type="device", - title="Mock subentry", - ), - ), - ( - ( - { - "device_class": "battery", - "options": [], - "state_class": "measurement", - "unit_of_measurement": "invalid", - }, - # Allow to accept options are being removed - { - "device_class": "options_device_class_enum", - "options": "options_not_allowed_with_state_class_or_uom", - "unit_of_measurement": "invalid_uom", - }, - ), - ), - { - "device_class": "battery", - "state_class": "measurement", - "unit_of_measurement": "%", - "advanced_settings": {"suggested_display_precision": 1}, - }, - { - "state_topic": "test-topic1-updated", - "value_template": "{{ value_json.value }}", - }, - { - "state_topic": "test-topic1-updated", - "value_template": "{{ value_json.value }}", - }, - {"options", "expire_after", "entity_picture"}, - ), - ( - ( - ConfigSubentryData( - data=MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + data=MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, subentry_type="device", title="Mock subentry", ), ), (), - {}, { - "command_topic": "test-topic1-updated", - "state_topic": "test-topic1-updated", - "light_brightness_settings": { - "brightness_command_template": "{{ value_json.value }}" + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "high_low", + "climate_feature_target_humidity": False, + }, + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool"], + # high/low target temperature + "target_temperature_settings": { + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, }, }, - { - "command_topic": "test-topic1-updated", - "state_topic": "test-topic1-updated", - "brightness_command_template": "{{ value_json.value }}", - }, - {"optimistic", "state_value_template", "entity_picture"}, + {}, + {"entity_picture"}, + id="climate_high_low", ), - ( + pytest.param( ( ConfigSubentryData( data=MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, @@ -3953,56 +4180,98 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "target_humidity_command_template", "swing_mode_state_topic", }, + id="climate_single", ), - ( + pytest.param( ( ConfigSubentryData( - data=MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, + data=MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, subentry_type="device", title="Mock subentry", ), ), (), + {}, { - "climate_feature_action": False, - "climate_feature_current_humidity": False, - "climate_feature_current_temperature": False, - "climate_feature_power": False, - "climate_feature_preset_modes": False, - "climate_feature_fan_modes": False, - "climate_feature_swing_horizontal_modes": False, - "climate_feature_swing_modes": False, - "climate_feature_target_temperature": "high_low", - "climate_feature_target_humidity": False, - }, - { - "mode_command_topic": "mode-command-topic", - "mode_command_template": "{{ value }}", - "mode_state_topic": "mode-state-topic", - "mode_state_template": "{{ value_json.mode }}", - "modes": ["off", "heat", "cool"], - # high/low target temperature - "target_temperature_settings": { - "temperature_low_command_topic": "temperature-low-command-topic", - "temperature_low_command_template": "{{ value }}", - "temperature_low_state_topic": "temperature-low-state-topic", - "temperature_low_state_template": "{{ value_json.temperature_low }}", - "temperature_high_command_topic": "temperature-high-command-topic", - "temperature_high_command_template": "{{ value }}", - "temperature_high_state_topic": "temperature-high-state-topic", - "temperature_high_state_template": "{{ value_json.temperature_high }}", - "min_temp": 8, - "max_temp": 28, - "precision": "0.1", - "temp_step": 1.0, - "initial": 19.0, + "command_topic": "test-topic1-updated", + "state_topic": "test-topic1-updated", + "light_brightness_settings": { + "brightness_command_template": "{{ value_json.value }}" }, }, + { + "command_topic": "test-topic1-updated", + "state_topic": "test-topic1-updated", + "brightness_command_template": "{{ value_json.value }}", + }, + {"optimistic", "state_value_template", "entity_picture"}, + id="light_basic", + ), + pytest.param( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), {}, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "retain": True, + }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "retain": True, + }, {"entity_picture"}, + id="notify", + ), + pytest.param( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + ( + ( + { + "device_class": "battery", + "options": [], + "state_class": "measurement", + "unit_of_measurement": "invalid", + }, + # Allow to accept options are being removed + { + "device_class": "options_device_class_enum", + "options": "options_not_allowed_with_state_class_or_uom", + "unit_of_measurement": "invalid_uom", + }, + ), + ), + { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", + "advanced_settings": {"suggested_display_precision": 1}, + }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, + {"options", "expire_after", "entity_picture"}, + id="sensor", ), ], - ids=["notify", "sensor", "light_basic", "climate_single", "climate_high_low"], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, @@ -4072,7 +4341,6 @@ async def test_subentry_reconfigure_edit_entity_single_entity( user_input={}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "entity_platform_config" # entity platform config flow step assert result["step_id"] == "entity_platform_config" @@ -4452,6 +4720,7 @@ async def test_subentry_reconfigure_update_device_properties( "advanced_settings": {"sw_version": "1.1"}, "model": "Beer bottle XL", "model_id": "bn003", + "manufacturer": "Beer Masters", "configuration_url": "https://example.com", "mqtt_settings": {"qos": 1}, }, @@ -4474,6 +4743,7 @@ async def test_subentry_reconfigure_update_device_properties( assert device["model"] == "Beer bottle XL" assert device["model_id"] == "bn003" assert device["sw_version"] == "1.1" + assert device["manufacturer"] == "Beer Masters" assert device["mqtt_settings"]["qos"] == 1 assert "qos" not in device diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 04b4bda0d79..841171046a0 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1331,7 +1331,7 @@ async def test_discover_alarm_control_panel( @pytest.mark.parametrize( - ("topic", "config", "entity_id", "name", "domain"), + ("topic", "config", "entity_id", "name", "domain", "deprecation_warning"), [ ( "homeassistant/alarm_control_panel/object/bla/config", @@ -1339,6 +1339,7 @@ async def test_discover_alarm_control_panel( "alarm_control_panel.hello_id", "Hello World 1", "alarm_control_panel", + True, ), ( "homeassistant/binary_sensor/object/bla/config", @@ -1346,6 +1347,7 @@ async def test_discover_alarm_control_panel( "binary_sensor.hello_id", "Hello World 2", "binary_sensor", + True, ), ( "homeassistant/button/object/bla/config", @@ -1353,6 +1355,7 @@ async def test_discover_alarm_control_panel( "button.hello_id", "Hello World button", "button", + True, ), ( "homeassistant/camera/object/bla/config", @@ -1360,6 +1363,7 @@ async def test_discover_alarm_control_panel( "camera.hello_id", "Hello World 3", "camera", + True, ), ( "homeassistant/climate/object/bla/config", @@ -1367,6 +1371,7 @@ async def test_discover_alarm_control_panel( "climate.hello_id", "Hello World 4", "climate", + True, ), ( "homeassistant/cover/object/bla/config", @@ -1374,6 +1379,7 @@ async def test_discover_alarm_control_panel( "cover.hello_id", "Hello World 5", "cover", + True, ), ( "homeassistant/fan/object/bla/config", @@ -1381,6 +1387,7 @@ async def test_discover_alarm_control_panel( "fan.hello_id", "Hello World 6", "fan", + True, ), ( "homeassistant/humidifier/object/bla/config", @@ -1388,6 +1395,7 @@ async def test_discover_alarm_control_panel( "humidifier.hello_id", "Hello World 7", "humidifier", + True, ), ( "homeassistant/number/object/bla/config", @@ -1395,6 +1403,7 @@ async def test_discover_alarm_control_panel( "number.hello_id", "Hello World 8", "number", + True, ), ( "homeassistant/scene/object/bla/config", @@ -1402,6 +1411,7 @@ async def test_discover_alarm_control_panel( "scene.hello_id", "Hello World 9", "scene", + True, ), ( "homeassistant/select/object/bla/config", @@ -1409,6 +1419,7 @@ async def test_discover_alarm_control_panel( "select.hello_id", "Hello World 10", "select", + True, ), ( "homeassistant/sensor/object/bla/config", @@ -1416,6 +1427,7 @@ async def test_discover_alarm_control_panel( "sensor.hello_id", "Hello World 11", "sensor", + True, ), ( "homeassistant/switch/object/bla/config", @@ -1423,6 +1435,7 @@ async def test_discover_alarm_control_panel( "switch.hello_id", "Hello World 12", "switch", + True, ), ( "homeassistant/light/object/bla/config", @@ -1430,6 +1443,7 @@ async def test_discover_alarm_control_panel( "light.hello_id", "Hello World 13", "light", + True, ), ( "homeassistant/light/object/bla/config", @@ -1437,6 +1451,7 @@ async def test_discover_alarm_control_panel( "light.hello_id", "Hello World 14", "light", + True, ), ( "homeassistant/light/object/bla/config", @@ -1444,6 +1459,7 @@ async def test_discover_alarm_control_panel( "light.hello_id", "Hello World 15", "light", + True, ), ( "homeassistant/vacuum/object/bla/config", @@ -1451,6 +1467,7 @@ async def test_discover_alarm_control_panel( "vacuum.hello_id", "Hello World 16", "vacuum", + True, ), ( "homeassistant/valve/object/bla/config", @@ -1458,6 +1475,7 @@ async def test_discover_alarm_control_panel( "valve.hello_id", "Hello World 17", "valve", + True, ), ( "homeassistant/lock/object/bla/config", @@ -1465,6 +1483,7 @@ async def test_discover_alarm_control_panel( "lock.hello_id", "Hello World 18", "lock", + True, ), ( "homeassistant/device_tracker/object/bla/config", @@ -1472,17 +1491,78 @@ async def test_discover_alarm_control_panel( "device_tracker.hello_id", "Hello World 19", "device_tracker", + True, + ), + ( + "homeassistant/binary_sensor/object/bla/config", + '{ "name": "Hello World 2", "obj_id": "hello_id", ' + '"o": {"name": "X2mqtt"}, "state_topic": "test-topic" }', + "binary_sensor.hello_id", + "Hello World 2", + "binary_sensor", + True, + ), + ( + "homeassistant/button/object/bla/config", + '{ "name": "Hello World button", "obj_id": "hello_id", ' + '"o": {"name": "X2mqtt", "url": "https://example.com/x2mqtt"}, ' + '"command_topic": "test-topic" }', + "button.hello_id", + "Hello World button", + "button", + True, + ), + ( + "homeassistant/alarm_control_panel/object/bla/config", + '{ "name": "Hello World 1", "def_ent_id": "alarm_control_panel.hello_id", ' + '"state_topic": "test-topic", "command_topic": "test-topic" }', + "alarm_control_panel.hello_id", + "Hello World 1", + "alarm_control_panel", + False, + ), + ( + "homeassistant/binary_sensor/object/bla/config", + '{ "name": "Hello World 2", "def_ent_id": "binary_sensor.hello_id", ' + '"o": {"name": "X2mqtt"}, "state_topic": "test-topic" }', + "binary_sensor.hello_id", + "Hello World 2", + "binary_sensor", + False, + ), + ( + "homeassistant/button/object/bla/config", + '{ "name": "Hello World button", "def_ent_id": "button.hello_id", ' + '"o": {"name": "X2mqtt", "url": "https://example.com/x2mqtt"}, ' + '"command_topic": "test-topic" }', + "button.hello_id", + "Hello World button", + "button", + False, + ), + ( + "homeassistant/button/object/bla/config", + '{ "name": "Hello World button", "def_ent_id": "button.hello_id", ' + '"obj_id": "hello_id_old", ' + '"o": {"name": "X2mqtt", "url": "https://example.com/x2mqtt"}, ' + '"command_topic": "test-topic" }', + "button.hello_id", + "Hello World button", + "button", + False, ), ], ) async def test_discovery_with_object_id( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, topic: str, config: str, entity_id: str, name: str, domain: str, + deprecation_warning: bool, ) -> None: """Test discovering an MQTT entity with object_id.""" await mqtt_mock_entry() @@ -1495,21 +1575,26 @@ async def test_discovery_with_object_id( assert state.name == name assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + assert ( + f"The configuration for entity {domain}.hello_id uses the deprecated option `object_id`" + in caplog.text + ) is deprecation_warning -async def test_discovery_with_object_id_for_previous_deleted_entity( + +async def test_discovery_with_default_entity_id_for_previous_deleted_entity( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: - """Test discovering an MQTT entity with object_id and unique_id.""" + """Test discovering an MQTT entity with default_entity_id and unique_id.""" topic = "homeassistant/sensor/object/bla/config" config = ( '{ "name": "Hello World 11", "unique_id": "very_unique", ' - '"obj_id": "hello_id", "state_topic": "test-topic" }' + '"def_ent_id": "sensor.hello_id", "state_topic": "test-topic" }' ) new_config = ( '{ "name": "Hello World 11", "unique_id": "very_unique", ' - '"obj_id": "updated_hello_id", "state_topic": "test-topic" }' + '"def_ent_id": "sensor.updated_hello_id", "state_topic": "test-topic" }' ) initial_entity_id = "sensor.hello_id" new_entity_id = "sensor.updated_hello_id" @@ -1531,7 +1616,7 @@ async def test_discovery_with_object_id_for_previous_deleted_entity( await hass.async_block_till_done() assert (domain, "object bla") not in hass.data["mqtt"].discovery_already_discovered - # Rediscover with new object_id + # Rediscover with new default_entity_id async_fire_mqtt_message(hass, topic, new_config) await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7f7f32c4e43..8c32926e08e 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -182,6 +182,19 @@ class JsonValidator: return json_loads(self.jsondata) == json_loads(other) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_simple_on_off_light( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if setup fails with no command topic.""" + assert await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state and state.state == STATE_UNKNOWN + assert state.attributes["supported_color_modes"] == ["onoff"] + + @pytest.mark.parametrize( "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}] ) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 4aa6ecd03ef..de2d77e69a7 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -75,6 +75,7 @@ CONFIG_WITH_STATES = { "state_opening": "opening", "state_unlocked": "unlocked", "state_unlocking": "unlocking", + "state_jammed": "jammed", } } } @@ -89,6 +90,7 @@ CONFIG_WITH_STATES = { (CONFIG_WITH_STATES, "opening", LockState.OPENING), (CONFIG_WITH_STATES, "unlocked", LockState.UNLOCKED), (CONFIG_WITH_STATES, "unlocking", LockState.UNLOCKING), + (CONFIG_WITH_STATES, "jammed", LockState.JAMMED), ], ) async def test_controlling_state_via_topic( @@ -111,6 +113,12 @@ async def test_controlling_state_via_topic( state = hass.states.get("lock.test") assert state.state == lock_state + async_fire_mqtt_message(hass, "state-topic", "None") + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state == STATE_UNKNOWN + @pytest.mark.parametrize( ("hass_config", "payload", "lock_state"), diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index fa30283962b..23c63c9ba58 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -368,7 +368,7 @@ async def test_name_attribute_is_set_or_not( hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Gate", "state_topic": "test-topic", "device_class": "door", ' - '"object_id": "gate",' + '"default_entity_id": "binary_sensor.gate",' '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' "}", ) @@ -384,7 +384,7 @@ async def test_name_attribute_is_set_or_not( hass, "homeassistant/binary_sensor/bla/config", '{ "state_topic": "test-topic", "device_class": "door", ' - '"object_id": "gate",' + '"default_entity_id": "binary_sensor.gate",' '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' "}", ) @@ -400,7 +400,7 @@ async def test_name_attribute_is_set_or_not( hass, "homeassistant/binary_sensor/bla/config", '{ "name": null, "state_topic": "test-topic", "device_class": "door", ' - '"object_id": "gate",' + '"default_entity_id": "binary_sensor.gate",' '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' "}", ) @@ -468,6 +468,40 @@ async def test_value_template_fails( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "object_id": "test", + } + } + }, + ], +) +async def test_deprecated_option_object_id_is_used_in_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test issue registry in case the deprecated option object_id was used in YAML.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state is not None + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(mqtt.DOMAIN, "sensor.test") + assert issue is not None + assert issue.translation_placeholders == { + "entity_id": "sensor.test", + "object_id": "test", + "domain": "sensor", + } + + @pytest.mark.parametrize( "mqtt_config_subentries_data", [ diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py index 56da809d1b6..cd919d3c94d 100644 --- a/tests/components/mqtt/test_notify.py +++ b/tests/components/mqtt/test_notify.py @@ -54,7 +54,7 @@ DEFAULT_CONFIG = { notify.DOMAIN: { "command_topic": "command-topic", "name": "test", - "object_id": "test_notify", + "default_entity_id": "notify.test_notify", "qos": "2", } } diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 072b1ece1a1..620a85ed893 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -186,15 +186,15 @@ async def trigger_subscription_callback( ): continue - event = MassEvent( + mass_event = MassEvent( event=event, object_id=object_id, data=data, ) if inspect.iscoroutinefunction(cb_func): - await cb_func(event) + await cb_func(mass_event) else: - cb_func(event) + cb_func(mass_event) await hass.async_block_till_done() diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py index 2f623c1188d..c9cb465b7c7 100644 --- a/tests/components/music_assistant/test_config_flow.py +++ b/tests/components/music_assistant/test_config_flow.py @@ -15,7 +15,7 @@ import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL from homeassistant.components.music_assistant.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -215,3 +215,188 @@ async def test_flow_zeroconf_connect_issue( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_user_url_different_from_server_base_url( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, +) -> None: + """Test that user-provided URL is used even when different from server base_url.""" + # Mock server info with a different base_url than what user will provide + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://different-server:8095" + mock_get_server_info.return_value = server_info + + user_url = "http://user-provided-server:8095" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"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"], + {CONF_URL: user_url}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + # Verify that the user-provided URL is stored, not the server's base_url + assert result["data"] == { + CONF_URL: user_url, + } + assert result["result"].unique_id == "1234" + + +async def test_duplicate_user_with_different_urls( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, +) -> None: + """Test duplicate detection works with different user URLs.""" + # Set up existing config entry with one URL + existing_url = "http://existing-server:8095" + existing_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Music Assistant", + data={CONF_URL: existing_url}, + unique_id="1234", + ) + existing_config_entry.add_to_hass(hass) + + # Mock server info with different base_url + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://server-reported-url:8095" + mock_get_server_info.return_value = server_info + + # Try to configure with a different user URL but same server_id + new_user_url = "http://new-user-url:8095" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: new_user_url}, + ) + await hass.async_block_till_done() + + # Should detect as duplicate because server_id is the same + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_existing_entry_working_url( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow when existing entry has working URL.""" + mock_config_entry.add_to_hass(hass) + + # Mock server info with different base_url + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://different-discovered-url:8095" + mock_get_server_info.return_value = server_info + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + # Should abort because current URL is working + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # Verify the URL was not changed + assert mock_config_entry.data[CONF_URL] == "http://localhost:8095" + + +async def test_zeroconf_existing_entry_broken_url( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow when existing entry has broken URL.""" + mock_config_entry.add_to_hass(hass) + + # Create modified zeroconf data with different base_url + modified_zeroconf_data = deepcopy(ZEROCONF_DATA) + modified_zeroconf_data.properties["base_url"] = "http://discovered-working-url:8095" + + # Mock server info with the discovered URL + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://discovered-working-url:8095" + mock_get_server_info.return_value = server_info + + # First call (testing current URL) should fail, second call (testing discovered URL) should succeed + mock_get_server_info.side_effect = [ + CannotConnect("cannot_connect"), # Current URL fails + server_info, # Discovered URL works + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=modified_zeroconf_data, + ) + await hass.async_block_till_done() + + # Should proceed to discovery confirm because current URL is broken + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + # Verify the URL was updated in the config entry + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_URL] == "http://discovered-working-url:8095" + + +async def test_zeroconf_existing_entry_ignored( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, +) -> None: + """Test zeroconf flow when existing entry was ignored.""" + # Create an ignored config entry (no URL field) + ignored_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Music Assistant", + data={}, # No URL field for ignored entries + unique_id="1234", + source=SOURCE_IGNORE, + ) + ignored_config_entry.add_to_hass(hass) + + # Mock server info with discovered URL + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://discovered-url:8095" + mock_get_server_info.return_value = server_info + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + # Should abort because entry was ignored (respect user's choice) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # Verify the ignored entry was not modified + ignored_entry = hass.config_entries.async_get_entry(ignored_config_entry.entry_id) + assert ignored_entry.data == {} # Still no URL field + assert ignored_entry.source == SOURCE_IGNORE diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py index 4cfefb50bd2..e088fd202bc 100644 --- a/tests/components/music_assistant/test_init.py +++ b/tests/components/music_assistant/test_init.py @@ -4,14 +4,18 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock +from music_assistant_models.enums import EventType from music_assistant_models.errors import ActionUnavailable -from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.components.music_assistant.const import ( + ATTR_CONF_EXPOSE_PLAYER_TO_HA, + DOMAIN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .common import setup_integration_from_fixtures +from .common import setup_integration_from_fixtures, trigger_subscription_callback from tests.typing import WebSocketGenerator @@ -68,3 +72,82 @@ async def test_remove_config_entry_device( response = await client.remove_device(device_entry.id, config_entry.entry_id) assert music_assistant_client.config.remove_player_config.call_count == 0 assert response["success"] is True + + +async def test_player_config_expose_to_ha_toggle( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + music_assistant_client: MagicMock, +) -> None: + """Test player exposure toggle via config update.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + await hass.async_block_till_done() + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + # Initial state: player should be exposed (from fixture) + entity_id = "media_player.test_player_1" + player_id = "00:00:00:00:00:01" + assert hass.states.get(entity_id) + assert entity_registry.async_get(entity_id) + device_entry = device_registry.async_get_device({(DOMAIN, player_id)}) + assert device_entry + assert player_id in config_entry.runtime_data.discovered_players + + # Simulate player config update: expose_to_ha = False + # Trigger the subscription callback + event_data = { + "player_id": player_id, + "provider": "test", + "values": { + ATTR_CONF_EXPOSE_PLAYER_TO_HA: { + "key": ATTR_CONF_EXPOSE_PLAYER_TO_HA, + "type": "boolean", + "value": False, + "label": ATTR_CONF_EXPOSE_PLAYER_TO_HA, + "default_value": True, + } + }, + } + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.PLAYER_CONFIG_UPDATED, + player_id, + event_data, + ) + + # Verify player was removed from HA + assert player_id not in config_entry.runtime_data.discovered_players + assert not hass.states.get(entity_id) + assert not entity_registry.async_get(entity_id) + device_entry = device_registry.async_get_device({(DOMAIN, player_id)}) + assert not device_entry + + # Now test re-adding the player: expose_to_ha = True + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.PLAYER_CONFIG_UPDATED, + player_id, + { + "player_id": player_id, + "provider": "test", + "values": { + ATTR_CONF_EXPOSE_PLAYER_TO_HA: { + "key": ATTR_CONF_EXPOSE_PLAYER_TO_HA, + "type": "boolean", + "value": True, + "label": ATTR_CONF_EXPOSE_PLAYER_TO_HA, + "default_value": True, + } + }, + }, + ) + + # Verify player was re-added to HA + assert player_id in config_entry.runtime_data.discovered_players + assert hass.states.get(entity_id) + assert entity_registry.async_get(entity_id) + device_entry = device_registry.async_get_device({(DOMAIN, player_id)}) + assert device_entry diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index 3071752267e..7ad641306b5 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -1812,7 +1812,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'SPS30 PM4', 'platform': 'nam', @@ -1827,6 +1827,7 @@ # name: test_sensor[sensor.nettigo_air_monitor_sps30_pm4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'pm4', 'friendly_name': 'Nettigo Air Monitor SPS30 PM4', 'state_class': , 'unit_of_measurement': 'μg/m³', diff --git a/tests/components/nederlandse_spoorwegen/__init__.py b/tests/components/nederlandse_spoorwegen/__init__.py new file mode 100644 index 00000000000..da2803d9efb --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Nederlandse Spoorwegen integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set 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/nederlandse_spoorwegen/conftest.py b/tests/components/nederlandse_spoorwegen/conftest.py new file mode 100644 index 00000000000..c2bcdfedd87 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/conftest.py @@ -0,0 +1,76 @@ +"""Fixtures for Nederlandse Spoorwegen tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from ns_api import Station, Trip +import pytest + +from homeassistant.components.nederlandse_spoorwegen.const import ( + CONF_FROM, + CONF_TO, + CONF_VIA, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_NAME + +from .const import API_KEY + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nederlandse_spoorwegen.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nsapi() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with ( + patch( + "homeassistant.components.nederlandse_spoorwegen.config_flow.NSAPI", + autospec=True, + ) as mock_nsapi, + patch( + "homeassistant.components.nederlandse_spoorwegen.NSAPI", + new=mock_nsapi, + ), + ): + client = mock_nsapi.return_value + stations = load_json_object_fixture("stations.json", DOMAIN) + client.get_stations.return_value = [ + Station(station) for station in stations["payload"] + ] + trips = load_json_object_fixture("trip.json", DOMAIN) + client.get_trips.return_value = [Trip(trip) for trip in trips["trips"]] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + title="Nederlandse Spoorwegen", + data={CONF_API_KEY: API_KEY}, + domain=DOMAIN, + subentries_data=[ + ConfigSubentryData( + data={ + CONF_NAME: "To work", + CONF_FROM: "Ams", + CONF_TO: "Rot", + CONF_VIA: "Ht", + }, + subentry_type="route", + title="Test Route", + unique_id=None, + ), + ], + ) diff --git a/tests/components/nederlandse_spoorwegen/const.py b/tests/components/nederlandse_spoorwegen/const.py new file mode 100644 index 00000000000..92c2a6e58f9 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/const.py @@ -0,0 +1,3 @@ +"""Constants for the Nederlandse Spoorwegen integration tests.""" + +API_KEY = "abc1234567" diff --git a/tests/components/nederlandse_spoorwegen/fixtures/stations.json b/tests/components/nederlandse_spoorwegen/fixtures/stations.json new file mode 100644 index 00000000000..207c8cda878 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/fixtures/stations.json @@ -0,0 +1,262 @@ +{ + "payload": [ + { + "EVACode": "8400058", + "UICCode": "8400058", + "UICCdCode": "118400058", + "cdCode": 58, + "code": "ASD", + "ingangsDatum": "2025-06-03", + "heeftFaciliteiten": true, + "heeftReisassistentie": true, + "heeftVertrektijden": true, + "land": "NL", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "radius": 525, + "naderenRadius": 1200, + "namen": { + "lang": "Amsterdam Centraal", + "middel": "Amsterdam C.", + "kort": "Amsterdm C" + }, + "synoniemen": ["Amsterdam CS", "Amsterdam"], + "nearbyMeLocationId": { + "value": "ASD", + "type": "stationV2" + }, + "sporen": [ + { + "spoorNummer": "1" + }, + { + "spoorNummer": "2" + }, + { + "spoorNummer": "2a" + }, + { + "spoorNummer": "2b" + }, + { + "spoorNummer": "4" + }, + { + "spoorNummer": "4a" + }, + { + "spoorNummer": "4b" + }, + { + "spoorNummer": "5" + }, + { + "spoorNummer": "5a" + }, + { + "spoorNummer": "5b" + }, + { + "spoorNummer": "7" + }, + { + "spoorNummer": "7a" + }, + { + "spoorNummer": "7b" + }, + { + "spoorNummer": "8" + }, + { + "spoorNummer": "8a" + }, + { + "spoorNummer": "8b" + }, + { + "spoorNummer": "10" + }, + { + "spoorNummer": "10a" + }, + { + "spoorNummer": "10b" + }, + { + "spoorNummer": "11" + }, + { + "spoorNummer": "11a" + }, + { + "spoorNummer": "11b" + }, + { + "spoorNummer": "13" + }, + { + "spoorNummer": "13a" + }, + { + "spoorNummer": "13b" + }, + { + "spoorNummer": "14" + }, + { + "spoorNummer": "14a" + }, + { + "spoorNummer": "14b" + }, + { + "spoorNummer": "15" + }, + { + "spoorNummer": "15a" + }, + { + "spoorNummer": "15b" + } + ], + "stationType": "MEGA_STATION" + }, + { + "EVACode": "8400319", + "UICCode": "8400319", + "UICCdCode": "118400319", + "cdCode": 319, + "code": "HT", + "ingangsDatum": "2025-06-03", + "heeftFaciliteiten": true, + "heeftReisassistentie": true, + "heeftVertrektijden": true, + "land": "NL", + "lat": 51.69048, + "lng": 5.29362, + "radius": 525, + "naderenRadius": 1200, + "namen": { + "lang": "'s-Hertogenbosch", + "middel": "'s-Hertogenbosch", + "kort": "Den Bosch" + }, + "synoniemen": ["Den Bosch", "Hertogenbosch ('s)"], + "nearbyMeLocationId": { + "value": "HT", + "type": "stationV2" + }, + "sporen": [ + { + "spoorNummer": "1" + }, + { + "spoorNummer": "3" + }, + { + "spoorNummer": "3a" + }, + { + "spoorNummer": "3b" + }, + { + "spoorNummer": "4" + }, + { + "spoorNummer": "4a" + }, + { + "spoorNummer": "4b" + }, + { + "spoorNummer": "6" + }, + { + "spoorNummer": "6a" + }, + { + "spoorNummer": "6b" + }, + { + "spoorNummer": "7" + }, + { + "spoorNummer": "7a" + }, + { + "spoorNummer": "7b" + } + ], + "stationType": "KNOOPPUNT_INTERCITY_STATION" + }, + { + "EVACode": "8400530", + "UICCode": "8400530", + "UICCdCode": "118400530", + "cdCode": 530, + "code": "RTD", + "ingangsDatum": "2017-02-01", + "heeftFaciliteiten": true, + "heeftReisassistentie": true, + "heeftVertrektijden": true, + "land": "NL", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "radius": 525, + "naderenRadius": 1000, + "namen": { + "lang": "Rotterdam Centraal", + "middel": "Rotterdam C.", + "kort": "Rotterdm C" + }, + "synoniemen": ["Rotterdam CS", "Rotterdam"], + "nearbyMeLocationId": { + "value": "RTD", + "type": "stationV2" + }, + "sporen": [ + { + "spoorNummer": "2" + }, + { + "spoorNummer": "3" + }, + { + "spoorNummer": "4" + }, + { + "spoorNummer": "6" + }, + { + "spoorNummer": "7" + }, + { + "spoorNummer": "8" + }, + { + "spoorNummer": "9" + }, + { + "spoorNummer": "11" + }, + { + "spoorNummer": "12" + }, + { + "spoorNummer": "13" + }, + { + "spoorNummer": "14" + }, + { + "spoorNummer": "15" + }, + { + "spoorNummer": "16" + } + ], + "stationType": "MEGA_STATION" + } + ] +} diff --git a/tests/components/nederlandse_spoorwegen/fixtures/trip.json b/tests/components/nederlandse_spoorwegen/fixtures/trip.json new file mode 100644 index 00000000000..e79c1523678 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/fixtures/trip.json @@ -0,0 +1,7096 @@ +{ + "source": "HARP", + "trips": [ + { + "idx": 0, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:24:00+02:00|plannedArrivalTime=2025-09-15T18:40:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=2333456918", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:24:00+02:00|plannedArrivalTime=2025-09-15T18:40:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=2333456918", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100850@a=128@$A=1@O=Utrecht Centraal@L=1100728@a=128@$202509151624$202509151651$IC 3059 $$1$$$$$$§W$A=1@O=Utrecht Centraal@L=1100728@a=128@$A=1@O=Utrecht Centraal@L=1100905@a=128@$202509151651$202509151653$$$1$$$$$$§T$A=1@O=Utrecht Centraal@L=1100905@a=128@$A=1@O='s-Hertogenbosch@L=1100870@a=128@$202509151654$202509151722$IC 3559 $$3$$$$$$§W$A=1@O='s-Hertogenbosch@L=1100870@a=128@$A=1@O='s-Hertogenbosch@L=1101032@a=128@$202509151722$202509151726$$$1$$$$$$§T$A=1@O='s-Hertogenbosch@L=1101032@a=128@$A=1@O=Utrecht Centraal@L=1100715@a=128@$202509151730$202509151757$IC 2760 $$3$$$$$$§W$A=1@O=Utrecht Centraal@L=1100715@a=128@$A=1@O=Utrecht Centraal@L=1100930@a=128@$202509151757$202509151802$$$1$$$$$$§T$A=1@O=Utrecht Centraal@L=1100930@a=128@$A=1@O=Rotterdam Centraal@L=1100690@a=128@$202509151803$202509151840$IC 2860 $$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#45319#HIN#460#ECK#13954|13944|14077|14080|0|0|485|13938|1|0|8|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 136, + "actualDurationInMinutes": 135, + "transfers": 3, + "status": "NORMAL", + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 3059", + "travelType": "PUBLIC_TRANSIT", + "direction": "Nijmegen", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#444542#TA#0#DA#150925#1S#1101028#1T#1504#LS#1101149#LT#1747#PU#784#RT#1#CA#IC#ZE#3059#ZB#IC 3059 #PC#1#FR#1101028#FT#1504#TO#1101149#TT#1747#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:24:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:25:00+0200", + "plannedTrack": "4b", + "actualTrack": "4b", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:51:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:51:00+0200", + "plannedTrack": "19", + "actualTrack": "19", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3059", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Nijmegen", + "shortValue": "richting Nijmegen", + "accessibilityValue": "richting Nijmegen", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "1 tussenstop", + "shortValue": "1 tussenstop", + "accessibilityValue": "1 tussenstop", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T16:24:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:25:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4b", + "plannedDepartureTrack": "4b", + "plannedArrivalTrack": "4b", + "actualArrivalTrack": "4b", + "departureDelayInSeconds": 60, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T16:32:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:33:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T16:31:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T16:32:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 60, + "arrivalDelayInSeconds": 60, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedArrivalDateTime": "2025-09-15T16:51:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T16:51:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "19", + "plannedDepartureTrack": "19", + "plannedArrivalTrack": "19", + "actualArrivalTrack": "19", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "bicycleSpotCount": 6, + "punctuality": 100.0, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#444542#TA#0#DA#150925#1S#1101028#1T#1504#LS#1101149#LT#1747#PU#784#RT#1#CA#IC#ZE#3059#ZB#IC 3059 #PC#1#FR#1101028#FT#1504#TO#1101149#TT#1747#&train=3059&datetime=2025-09-15T16:24:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 27, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "26 min.", + "accessibilityValue": "26 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 37529 + }, + { + "idx": "1", + "name": "IC 3559", + "travelType": "PUBLIC_TRANSIT", + "direction": "Venlo", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#507406#TA#0#DA#150925#1S#1101787#1T#1504#LS#1100958#LT#1828#PU#784#RT#3#CA#IC#ZE#3559#ZB#IC 3559 #PC#1#FR#1101787#FT#1504#TO#1100958#TT#1828#", + "origin": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:54:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:54:00+0200", + "plannedTrack": "18", + "actualTrack": "18", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:22:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:22:00+0200", + "plannedTrack": "6", + "actualTrack": "6", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3559", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Venlo", + "shortValue": "richting Venlo", + "accessibilityValue": "richting Venlo", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "3 min. overstaptijd", + "accessibilityMessage": "3 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T16:54:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:54:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "18", + "plannedDepartureTrack": "18", + "plannedArrivalTrack": "18", + "actualArrivalTrack": "18", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 8, + "plannedArrivalDateTime": "2025-09-15T17:22:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:22:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "HIGH", + "bicycleSpotCount": 6, + "punctuality": 77.8, + "crossPlatformTransfer": false, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#507406#TA#0#DA#150925#1S#1101787#1T#1504#LS#1100958#LT#1828#PU#784#RT#3#CA#IC#ZE#3559#ZB#IC 3559 #PC#1#FR#1101787#FT#1504#TO#1100958#TT#1828#&train=3559&datetime=2025-09-15T16:54:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 28, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "28 min.", + "accessibilityValue": "28 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 4, + "distanceInMeters": 47266 + }, + { + "idx": "2", + "name": "IC 2760", + "travelType": "PUBLIC_TRANSIT", + "direction": "Alkmaar", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505746#TA#0#DA#150925#1S#1101011#1T#1559#LS#1101009#LT#1903#PU#784#RT#3#CA#IC#ZE#2760#ZB#IC 2760 #PC#1#FR#1101011#FT#1559#TO#1101009#TT#1903#", + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:30:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:30:00+0200", + "plannedTrack": "3", + "actualTrack": "3", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:57:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:57:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2760", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Alkmaar", + "shortValue": "richting Alkmaar", + "accessibilityValue": "richting Alkmaar", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "8 min. overstaptijd", + "accessibilityMessage": "8 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T17:30:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:30:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "3", + "plannedDepartureTrack": "3", + "plannedArrivalTrack": "3", + "actualArrivalTrack": "3", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 8, + "plannedArrivalDateTime": "2025-09-15T17:57:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:57:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "crossPlatformTransfer": false, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505746#TA#0#DA#150925#1S#1101011#1T#1559#LS#1101009#LT#1903#PU#784#RT#3#CA#IC#ZE#2760#ZB#IC 2760 #PC#1#FR#1101011#FT#1559#TO#1101009#TT#1903#&train=2760&datetime=2025-09-15T17:30:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 27, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "27 min.", + "accessibilityValue": "27 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 5, + "distanceInMeters": 47266 + }, + { + "idx": "3", + "name": "IC 2860", + "travelType": "PUBLIC_TRANSIT", + "direction": "Rotterdam Centraal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1180#TA#0#DA#150925#1S#1100930#1T#1803#LS#1100690#LT#1840#PU#784#RT#1#CA#IC#ZE#2860#ZB#IC 2860 #PC#1#FR#1100930#FT#1803#TO#1100690#TT#1840#", + "origin": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:03:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:03:00+0200", + "plannedTrack": "12", + "actualTrack": "12", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:40:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:40:00+0200", + "plannedTrack": "14", + "actualTrack": "14", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2860", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Rotterdam Centraal", + "shortValue": "richting Rotterdam Centraal", + "accessibilityValue": "richting Rotterdam Centraal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "6 min. overstaptijd", + "accessibilityMessage": "6 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:03:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:03:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "12", + "plannedDepartureTrack": "12", + "plannedArrivalTrack": "12", + "actualArrivalTrack": "12", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400258", + "uicCdCode": "118400258", + "name": "Gouda", + "lat": 52.0175018310547, + "lng": 4.70444440841675, + "countryCode": "NL", + "notes": [], + "routeIdx": 6, + "plannedDepartureDateTime": "2025-09-15T18:22:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:22:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T18:21:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:21:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "8", + "plannedDepartureTrack": "8", + "plannedArrivalTrack": "8", + "actualArrivalTrack": "8", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400507", + "uicCdCode": "118400507", + "name": "Rotterdam Alexander", + "lat": 51.9519462585449, + "lng": 4.55361127853394, + "countryCode": "NL", + "notes": [], + "routeIdx": 9, + "plannedDepartureDateTime": "2025-09-15T18:31:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:31:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T18:31:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:31:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "2", + "plannedDepartureTrack": "2", + "plannedArrivalTrack": "2", + "actualArrivalTrack": "2", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 11, + "plannedArrivalDateTime": "2025-09-15T18:40:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:40:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "14", + "plannedDepartureTrack": "14", + "plannedArrivalTrack": "14", + "actualArrivalTrack": "14", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 9, + "punctuality": 90.0, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1180#TA#0#DA#150925#1S#1100930#1T#1803#LS#1100690#LT#1840#PU#784#RT#1#CA#IC#ZE#2860#ZB#IC 2860 #PC#1#FR#1100930#FT#1803#TO#1100690#TT#1840#&train=2860&datetime=2025-09-15T18:03:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 37, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "37 min.", + "accessibilityValue": "37 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "distanceInMeters": 50987 + } + ], + "checksum": "2dbeefb4_3", + "crowdForecast": "HIGH", + "punctuality": 77.8, + "optimal": false, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1910, + "priceInCentsExcludingSupplement": 1910, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + }, + { + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 2010, + "priceInCentsExcludingSupplement": 2010, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 3920, + "priceInCentsExcludingSupplement": 3920, + "buyableTicketPriceInCents": 3920, + "buyableTicketPriceInCentsExcludingSupplement": 3920, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1624/1840?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A24%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T18%3A40%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D2333456918" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A24%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T18%3A40%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D2333456918&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4b", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "18", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "3", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "12", + "accessibilityName": "Intercity" + } + ] + }, + { + "idx": 1, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:34:00+02:00|plannedArrivalTime=2025-09-15T18:37:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1180343963", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:34:00+02:00|plannedArrivalTime=2025-09-15T18:37:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1180343963", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100836@a=128@$A=1@O='s-Hertogenbosch@L=1100870@a=128@$202509151634$202509151731$IC 2761 $$1$$$$$$§W$A=1@O='s-Hertogenbosch@L=1100870@a=128@$A=1@O='s-Hertogenbosch@L=1101751@a=128@$202509151731$202509151733$$$1$$$$$$§T$A=1@O='s-Hertogenbosch@L=1101751@a=128@$A=1@O=Breda@L=1101034@a=128@$202509151741$202509151810$IC 3661 $$3$$$$$$§W$A=1@O=Breda@L=1101034@a=128@$A=1@O=Breda@L=1100942@a=128@$202509151810$202509151812$$$1$$$$$$§T$A=1@O=Breda@L=1100942@a=128@$A=1@O=Rotterdam Centraal@L=1100726@a=128@$202509151814$202509151837$ICD 1871$$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#261#HIN#390#ECK#13954|13954|14077|14077|0|0|66021|13938|2|0|2|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 123, + "actualDurationInMinutes": 122, + "transfers": 2, + "status": "NORMAL", + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 2761", + "travelType": "PUBLIC_TRANSIT", + "direction": "Maastricht", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1088#TA#0#DA#150925#1S#1101009#1T#1557#LS#1101011#LT#1903#PU#784#RT#1#CA#IC#ZE#2761#ZB#IC 2761 #PC#1#FR#1101009#FT#1557#TO#1101011#TT#1903#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:34:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:35:00+0200", + "plannedTrack": "4", + "actualTrack": "4", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:31:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:31:00+0200", + "plannedTrack": "6", + "actualTrack": "6", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2761", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Maastricht", + "shortValue": "richting Maastricht", + "accessibilityValue": "richting Maastricht", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T16:34:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:35:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 60, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T16:42:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:43:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T16:42:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T16:43:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 60, + "arrivalDelayInSeconds": 60, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedDepartureDateTime": "2025-09-15T17:03:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:03:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:00:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:00:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "15", + "plannedDepartureTrack": "15", + "plannedArrivalTrack": "15", + "actualArrivalTrack": "15", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 18, + "plannedArrivalDateTime": "2025-09-15T17:31:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:31:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "bicycleSpotCount": 6, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1088#TA#0#DA#150925#1S#1101009#1T#1557#LS#1101011#LT#1903#PU#784#RT#1#CA#IC#ZE#2761#ZB#IC 2761 #PC#1#FR#1101009#FT#1557#TO#1101011#TT#1903#&train=2761&datetime=2025-09-15T16:34:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 57, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "56 min.", + "accessibilityValue": "56 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 84795 + }, + { + "idx": "1", + "name": "IC 3661", + "travelType": "PUBLIC_TRANSIT", + "direction": "Roosendaal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505945#TA#0#DA#150925#1S#1101167#1T#1550#LS#1101102#LT#1833#PU#784#RT#3#CA#IC#ZE#3661#ZB#IC 3661 #PC#1#FR#1101167#FT#1550#TO#1101102#TT#1833#", + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:41:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:41:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:10:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:10:00+0200", + "plannedTrack": "8", + "actualTrack": "8", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3661", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Roosendaal", + "shortValue": "richting Roosendaal", + "accessibilityValue": "richting Roosendaal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "1 tussenstop", + "shortValue": "1 tussenstop", + "accessibilityValue": "1 tussenstop", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T17:41:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:41:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400597", + "uicCdCode": "118400597", + "name": "Tilburg", + "lat": 51.5605545043945, + "lng": 5.08361101150513, + "countryCode": "NL", + "notes": [], + "routeIdx": 1, + "plannedDepartureDateTime": "2025-09-15T17:58:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:58:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:56:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:56:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "3", + "plannedDepartureTrack": "3", + "plannedArrivalTrack": "3", + "actualArrivalTrack": "3", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 5, + "plannedArrivalDateTime": "2025-09-15T18:10:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:10:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "8", + "plannedDepartureTrack": "8", + "plannedArrivalTrack": "8", + "actualArrivalTrack": "8", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "punctuality": 58.3, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505945#TA#0#DA#150925#1S#1101167#1T#1550#LS#1101102#LT#1833#PU#784#RT#3#CA#IC#ZE#3661#ZB#IC 3661 #PC#1#FR#1101167#FT#1550#TO#1101102#TT#1833#&train=3661&datetime=2025-09-15T17:41:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 29, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "29 min.", + "accessibilityValue": "29 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 41871 + }, + { + "idx": "2", + "name": "ICD 1871", + "travelType": "PUBLIC_TRANSIT", + "direction": "Amersfoort Schothorst", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#442492#TA#1#DA#150925#1S#1100942#1T#1814#LS#1100687#LT#1954#PU#784#RT#1#CA#ICD#ZE#1871#ZB#ICD 1871#PC#1#FR#1100942#FT#1814#TO#1100687#TT#1954#", + "origin": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:14:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:14:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:37:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:37:00+0200", + "plannedTrack": "12", + "actualTrack": "12", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "1871", + "categoryCode": "ICD", + "shortCategoryName": "ICD", + "longCategoryName": "Intercity direct", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity direct", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity direct", + "shortValue": "NS Intercity direct", + "accessibilityValue": "NS Intercity direct", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Amersfoort Schothorst via Schiphol Airport", + "shortValue": "richting Amersfoort Schothorst via Schiphol Airport", + "accessibilityValue": "richting Amersfoort Schothorst via Schiphol Airport", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:14:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:14:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 6, + "plannedArrivalDateTime": "2025-09-15T18:37:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:37:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "12", + "plannedDepartureTrack": "12", + "plannedArrivalTrack": "12", + "actualArrivalTrack": "12", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 16, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#442492#TA#1#DA#150925#1S#1100942#1T#1814#LS#1100687#LT#1954#PU#784#RT#1#CA#ICD#ZE#1871#ZB#ICD 1871#PC#1#FR#1100942#FT#1814#TO#1100687#TT#1954#&train=1871&datetime=2025-09-15T18:14:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 23, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "23 min.", + "accessibilityValue": "23 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "recognizableDestination": "Schiphol Airport", + "distanceInMeters": 44166 + } + ], + "checksum": "dc23b827_3", + "crowdForecast": "MEDIUM", + "punctuality": 58.3, + "optimal": true, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1910, + "priceInCentsExcludingSupplement": 1910, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + }, + { + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 2010, + "priceInCentsExcludingSupplement": 2010, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 3920, + "priceInCentsExcludingSupplement": 3920, + "buyableTicketPriceInCents": 3920, + "buyableTicketPriceInCentsExcludingSupplement": 3920, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1634/1837?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A34%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T18%3A37%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1180343963" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A34%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T18%3A37%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1180343963&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity direct", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity direct" + } + ] + }, + { + "idx": 2, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:34:00+02:00|plannedArrivalTime=2025-09-15T18:45:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1596512355", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:34:00+02:00|plannedArrivalTime=2025-09-15T18:45:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1596512355", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100836@a=128@$A=1@O='s-Hertogenbosch@L=1100870@a=128@$202509151634$202509151731$IC 2761 $$1$$$$$$§W$A=1@O='s-Hertogenbosch@L=1100870@a=128@$A=1@O='s-Hertogenbosch@L=1101751@a=128@$202509151731$202509151733$$$1$$$$$$§T$A=1@O='s-Hertogenbosch@L=1101751@a=128@$A=1@O=Breda@L=1101034@a=128@$202509151741$202509151810$IC 3661 $$3$$$$$$§W$A=1@O=Breda@L=1101034@a=128@$A=1@O=Breda@L=1100942@a=128@$202509151810$202509151812$$$1$$$$$$§T$A=1@O=Breda@L=1100942@a=128@$A=1@O=Rotterdam Centraal@L=1100668@a=128@$202509151823$202509151845$IC 1162 $$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#45317#HIN#390#ECK#13954|13954|14077|14085|0|0|485|13938|3|0|8|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 131, + "actualDurationInMinutes": 130, + "transfers": 2, + "status": "NORMAL", + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 2761", + "travelType": "PUBLIC_TRANSIT", + "direction": "Maastricht", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1088#TA#0#DA#150925#1S#1101009#1T#1557#LS#1101011#LT#1903#PU#784#RT#1#CA#IC#ZE#2761#ZB#IC 2761 #PC#1#FR#1101009#FT#1557#TO#1101011#TT#1903#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:34:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:35:00+0200", + "plannedTrack": "4", + "actualTrack": "4", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:31:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:31:00+0200", + "plannedTrack": "6", + "actualTrack": "6", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2761", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Maastricht", + "shortValue": "richting Maastricht", + "accessibilityValue": "richting Maastricht", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T16:34:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:35:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 60, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T16:42:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:43:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T16:42:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T16:43:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 60, + "arrivalDelayInSeconds": 60, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedDepartureDateTime": "2025-09-15T17:03:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:03:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:00:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:00:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "15", + "plannedDepartureTrack": "15", + "plannedArrivalTrack": "15", + "actualArrivalTrack": "15", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 18, + "plannedArrivalDateTime": "2025-09-15T17:31:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:31:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "bicycleSpotCount": 6, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1088#TA#0#DA#150925#1S#1101009#1T#1557#LS#1101011#LT#1903#PU#784#RT#1#CA#IC#ZE#2761#ZB#IC 2761 #PC#1#FR#1101009#FT#1557#TO#1101011#TT#1903#&train=2761&datetime=2025-09-15T16:34:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 57, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "56 min.", + "accessibilityValue": "56 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 84795 + }, + { + "idx": "1", + "name": "IC 3661", + "travelType": "PUBLIC_TRANSIT", + "direction": "Roosendaal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505945#TA#0#DA#150925#1S#1101167#1T#1550#LS#1101102#LT#1833#PU#784#RT#3#CA#IC#ZE#3661#ZB#IC 3661 #PC#1#FR#1101167#FT#1550#TO#1101102#TT#1833#", + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:41:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:41:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:10:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:10:00+0200", + "plannedTrack": "8", + "actualTrack": "8", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3661", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Roosendaal", + "shortValue": "richting Roosendaal", + "accessibilityValue": "richting Roosendaal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "1 tussenstop", + "shortValue": "1 tussenstop", + "accessibilityValue": "1 tussenstop", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T17:41:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:41:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400597", + "uicCdCode": "118400597", + "name": "Tilburg", + "lat": 51.5605545043945, + "lng": 5.08361101150513, + "countryCode": "NL", + "notes": [], + "routeIdx": 1, + "plannedDepartureDateTime": "2025-09-15T17:58:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:58:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:56:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:56:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "3", + "plannedDepartureTrack": "3", + "plannedArrivalTrack": "3", + "actualArrivalTrack": "3", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 5, + "plannedArrivalDateTime": "2025-09-15T18:10:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:10:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "8", + "plannedDepartureTrack": "8", + "plannedArrivalTrack": "8", + "actualArrivalTrack": "8", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "punctuality": 58.3, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505945#TA#0#DA#150925#1S#1101167#1T#1550#LS#1101102#LT#1833#PU#784#RT#3#CA#IC#ZE#3661#ZB#IC 3661 #PC#1#FR#1101167#FT#1550#TO#1101102#TT#1833#&train=3661&datetime=2025-09-15T17:41:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 29, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "29 min.", + "accessibilityValue": "29 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 41871 + }, + { + "idx": "2", + "name": "IC 1162", + "travelType": "PUBLIC_TRANSIT", + "direction": "Den Haag Centraal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#51#TA#9#DA#150925#1S#1100921#1T#1743#LS#1101078#LT#1911#PU#784#RT#1#CA#IC#ZE#1162#ZB#IC 1162 #PC#1#FR#1100921#FT#1743#TO#1101078#TT#1911#", + "origin": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:23:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:23:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:45:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:45:00+0200", + "plannedTrack": "13", + "actualTrack": "13", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "1162", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Den Haag Centraal", + "shortValue": "richting Den Haag Centraal", + "accessibilityValue": "richting Den Haag Centraal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:23:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:23:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 6, + "plannedArrivalDateTime": "2025-09-15T18:45:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:45:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "13", + "plannedDepartureTrack": "13", + "plannedArrivalTrack": "13", + "actualArrivalTrack": "13", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 16, + "punctuality": 81.8, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#51#TA#9#DA#150925#1S#1100921#1T#1743#LS#1101078#LT#1911#PU#784#RT#1#CA#IC#ZE#1162#ZB#IC 1162 #PC#1#FR#1100921#FT#1743#TO#1101078#TT#1911#&train=1162&datetime=2025-09-15T18:23:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 22, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "22 min.", + "accessibilityValue": "22 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "distanceInMeters": 44166 + } + ], + "checksum": "fe950328_3", + "crowdForecast": "MEDIUM", + "punctuality": 58.3, + "optimal": false, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1910, + "priceInCentsExcludingSupplement": 1910, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + }, + { + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 2010, + "priceInCentsExcludingSupplement": 2010, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 3920, + "priceInCentsExcludingSupplement": 3920, + "buyableTicketPriceInCents": 3920, + "buyableTicketPriceInCentsExcludingSupplement": 3920, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1634/1845?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A34%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T18%3A45%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1596512355" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A34%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T18%3A45%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1596512355&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity" + } + ] + }, + { + "idx": 3, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:46:00+02:00|plannedArrivalTime=2025-09-15T19:10:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=2212456600", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:46:00+02:00|plannedArrivalTime=2025-09-15T19:10:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=2212456600", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100850@a=128@$A=1@O='s-Hertogenbosch@L=1100870@a=128@$202509151646$202509151742$IC 3961 $$1$$$$$$§W$A=1@O='s-Hertogenbosch@L=1100870@a=128@$A=1@O='s-Hertogenbosch@L=1101032@a=128@$202509151742$202509151746$$$1$$$$$$§T$A=1@O='s-Hertogenbosch@L=1101032@a=128@$A=1@O=Utrecht Centraal@L=1100715@a=128@$202509151750$202509151816$IC 3962 $$1$$$$$$§W$A=1@O=Utrecht Centraal@L=1100715@a=128@$A=1@O=Utrecht Centraal@L=1100672@a=128@$202509151816$202509151821$$$1$$$$$$§T$A=1@O=Utrecht Centraal@L=1100672@a=128@$A=1@O=Rotterdam Centraal@L=1100690@a=128@$202509151833$202509151910$IC 2862 $$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#45317#HIN#460#ECK#13985|13966|14107|14110|0|0|66021|13955|4|0|8|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 144, + "actualDurationInMinutes": 144, + "transfers": 2, + "status": "NORMAL", + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 3961", + "travelType": "PUBLIC_TRANSIT", + "direction": "Heerlen", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#445919#TA#0#DA#150925#1S#1101046#1T#1539#LS#1101030#LT#1911#PU#784#RT#1#CA#IC#ZE#3961#ZB#IC 3961 #PC#1#FR#1101046#FT#1539#TO#1101030#TT#1911#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:46:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:46:00+0200", + "plannedTrack": "4b", + "actualTrack": "4b", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:42:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:42:00+0200", + "plannedTrack": "6", + "actualTrack": "6", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3961", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Heerlen", + "shortValue": "richting Heerlen", + "accessibilityValue": "richting Heerlen", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T16:46:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:46:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4b", + "plannedDepartureTrack": "4b", + "plannedArrivalTrack": "4b", + "actualArrivalTrack": "4b", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T16:53:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:53:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T16:53:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T16:53:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedDepartureDateTime": "2025-09-15T17:14:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:14:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:12:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:12:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "18", + "plannedDepartureTrack": "18", + "plannedArrivalTrack": "18", + "actualArrivalTrack": "18", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 18, + "plannedArrivalDateTime": "2025-09-15T17:42:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:42:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 6, + "punctuality": 66.7, + "crossPlatformTransfer": false, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#445919#TA#0#DA#150925#1S#1101046#1T#1539#LS#1101030#LT#1911#PU#784#RT#1#CA#IC#ZE#3961#ZB#IC 3961 #PC#1#FR#1101046#FT#1539#TO#1101030#TT#1911#&train=3961&datetime=2025-09-15T16:46:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 56, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "56 min.", + "accessibilityValue": "56 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 4, + "distanceInMeters": 84795 + }, + { + "idx": "1", + "name": "IC 3962", + "travelType": "PUBLIC_TRANSIT", + "direction": "Enkhuizen", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#3719#TA#0#DA#150925#1S#1101030#1T#1619#LS#1101046#LT#1953#PU#784#RT#1#CA#IC#ZE#3962#ZB#IC 3962 #PC#1#FR#1101030#FT#1619#TO#1101046#TT#1953#", + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:50:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:50:00+0200", + "plannedTrack": "3", + "actualTrack": "3", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:16:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:16:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3962", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Enkhuizen", + "shortValue": "richting Enkhuizen", + "accessibilityValue": "richting Enkhuizen", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "8 min. overstaptijd", + "accessibilityMessage": "8 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T17:50:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:50:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "3", + "plannedDepartureTrack": "3", + "plannedArrivalTrack": "3", + "actualArrivalTrack": "3", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 8, + "plannedArrivalDateTime": "2025-09-15T18:16:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:16:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "bicycleSpotCount": 6, + "punctuality": 100.0, + "crossPlatformTransfer": false, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#3719#TA#0#DA#150925#1S#1101030#1T#1619#LS#1101046#LT#1953#PU#784#RT#1#CA#IC#ZE#3962#ZB#IC 3962 #PC#1#FR#1101030#FT#1619#TO#1101046#TT#1953#&train=3962&datetime=2025-09-15T17:50:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 26, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "26 min.", + "accessibilityValue": "26 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 5, + "distanceInMeters": 47266 + }, + { + "idx": "2", + "name": "IC 2862", + "travelType": "PUBLIC_TRANSIT", + "direction": "Rotterdam Centraal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1137#TA#2#DA#150925#1S#1100672#1T#1833#LS#1100690#LT#1910#PU#784#RT#1#CA#IC#ZE#2862#ZB#IC 2862 #PC#1#FR#1100672#FT#1833#TO#1100690#TT#1910#", + "origin": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:33:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:33:00+0200", + "plannedTrack": "11", + "actualTrack": "11", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T19:10:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T19:10:00+0200", + "plannedTrack": "14", + "actualTrack": "14", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2862", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Rotterdam Centraal", + "shortValue": "richting Rotterdam Centraal", + "accessibilityValue": "richting Rotterdam Centraal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "17 min. overstaptijd", + "accessibilityMessage": "17 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:33:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:33:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "11", + "plannedDepartureTrack": "11", + "plannedArrivalTrack": "11", + "actualArrivalTrack": "11", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400258", + "uicCdCode": "118400258", + "name": "Gouda", + "lat": 52.0175018310547, + "lng": 4.70444440841675, + "countryCode": "NL", + "notes": [], + "routeIdx": 6, + "plannedDepartureDateTime": "2025-09-15T18:52:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:52:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T18:51:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:51:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "8", + "plannedDepartureTrack": "8", + "plannedArrivalTrack": "8", + "actualArrivalTrack": "8", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400507", + "uicCdCode": "118400507", + "name": "Rotterdam Alexander", + "lat": 51.9519462585449, + "lng": 4.55361127853394, + "countryCode": "NL", + "notes": [], + "routeIdx": 9, + "plannedDepartureDateTime": "2025-09-15T19:01:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T19:01:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T19:01:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T19:01:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "2", + "plannedDepartureTrack": "2", + "plannedArrivalTrack": "2", + "actualArrivalTrack": "2", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 11, + "plannedArrivalDateTime": "2025-09-15T19:10:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T19:10:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "14", + "plannedDepartureTrack": "14", + "plannedArrivalTrack": "14", + "actualArrivalTrack": "14", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 6, + "punctuality": 88.9, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1137#TA#2#DA#150925#1S#1100672#1T#1833#LS#1100690#LT#1910#PU#784#RT#1#CA#IC#ZE#2862#ZB#IC 2862 #PC#1#FR#1100672#FT#1833#TO#1100690#TT#1910#&train=2862&datetime=2025-09-15T18:33:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 37, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "37 min.", + "accessibilityValue": "37 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "distanceInMeters": 50987 + } + ], + "checksum": "83293275_3", + "crowdForecast": "MEDIUM", + "punctuality": 66.7, + "optimal": false, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1910, + "priceInCentsExcludingSupplement": 1910, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + }, + { + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 2010, + "priceInCentsExcludingSupplement": 2010, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 3920, + "priceInCentsExcludingSupplement": 3920, + "buyableTicketPriceInCents": 3920, + "buyableTicketPriceInCentsExcludingSupplement": 3920, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1646/1910?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A46%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A10%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D2212456600" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A46%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A10%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D2212456600&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4b", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "3", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "11", + "accessibilityName": "Intercity" + } + ] + }, + { + "idx": 4, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:46:00+02:00|plannedArrivalTime=2025-09-15T19:15:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=3511802831", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:46:00+02:00|plannedArrivalTime=2025-09-15T19:15:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=3511802831", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100850@a=128@$A=1@O=Eindhoven Centraal@L=1100884@a=128@$202509151646$202509151803$IC 3961 $$1$$$$$$§W$A=1@O=Eindhoven Centraal@L=1100884@a=128@$A=1@O=Eindhoven Centraal@L=1100921@a=128@$202509151803$202509151808$$$1$$$$$$§T$A=1@O=Eindhoven Centraal@L=1100921@a=128@$A=1@O=Rotterdam Centraal@L=1100726@a=128@$202509151814$202509151915$IC 1164 $$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#8451#HIN#460#ECK#13985|13966|14107|14115|0|0|485|13955|5|0|10|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 149, + "actualDurationInMinutes": 149, + "transfers": 1, + "status": "NORMAL", + "primaryMessage": { + "title": "Kortere trein, extra druk", + "nesProperties": { + "color": "text-warning-contrast", + "type": "warning", + "icon": "alert" + }, + "type": "SHORTENED_TRAIN" + }, + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 3961", + "travelType": "PUBLIC_TRANSIT", + "direction": "Heerlen", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#445919#TA#0#DA#150925#1S#1101046#1T#1539#LS#1101030#LT#1911#PU#784#RT#1#CA#IC#ZE#3961#ZB#IC 3961 #PC#1#FR#1101046#FT#1539#TO#1101030#TT#1911#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:46:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:46:00+0200", + "plannedTrack": "4b", + "actualTrack": "4b", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Eindhoven Centraal", + "lng": 5.48138904571533, + "lat": 51.4433326721191, + "countryCode": "NL", + "uicCode": "8400206", + "uicCdCode": "118400206", + "stationCode": "EHV", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:03:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:03:00+0200", + "plannedTrack": "2", + "actualTrack": "2", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3961", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Heerlen", + "shortValue": "richting Heerlen", + "accessibilityValue": "richting Heerlen", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "3 tussenstops", + "shortValue": "3 tussenstops", + "accessibilityValue": "3 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T16:46:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:46:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4b", + "plannedDepartureTrack": "4b", + "plannedArrivalTrack": "4b", + "actualArrivalTrack": "4b", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T16:53:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:53:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T16:53:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T16:53:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedDepartureDateTime": "2025-09-15T17:14:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:14:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:12:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:12:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "18", + "plannedDepartureTrack": "18", + "plannedArrivalTrack": "18", + "actualArrivalTrack": "18", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 18, + "plannedDepartureDateTime": "2025-09-15T17:45:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:45:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:42:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:42:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400206", + "uicCdCode": "118400206", + "name": "Eindhoven Centraal", + "lat": 51.4433326721191, + "lng": 5.48138904571533, + "countryCode": "NL", + "notes": [], + "routeIdx": 23, + "plannedArrivalDateTime": "2025-09-15T18:03:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:03:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "2", + "plannedDepartureTrack": "2", + "plannedArrivalTrack": "2", + "actualArrivalTrack": "2", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 6, + "punctuality": 77.8, + "crossPlatformTransfer": false, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#445919#TA#0#DA#150925#1S#1101046#1T#1539#LS#1101030#LT#1911#PU#784#RT#1#CA#IC#ZE#3961#ZB#IC 3961 #PC#1#FR#1101046#FT#1539#TO#1101030#TT#1911#&train=3961&datetime=2025-09-15T16:46:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 77, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "1:17 u.", + "accessibilityValue": "1 uur en 17 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 5, + "distanceInMeters": 116337 + }, + { + "idx": "1", + "name": "IC 1164", + "travelType": "PUBLIC_TRANSIT", + "direction": "Den Haag Centraal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#441788#TA#0#DA#150925#1S#1100921#1T#1814#LS#1101078#LT#1941#PU#784#RT#1#CA#IC#ZE#1164#ZB#IC 1164 #PC#1#FR#1100921#FT#1814#TO#1101078#TT#1941#", + "origin": { + "name": "Eindhoven Centraal", + "lng": 5.48138904571533, + "lat": 51.4433326721191, + "countryCode": "NL", + "uicCode": "8400206", + "uicCdCode": "118400206", + "stationCode": "EHV", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:14:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:14:00+0200", + "plannedTrack": "5", + "actualTrack": "5", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T19:15:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T19:15:00+0200", + "plannedTrack": "12", + "actualTrack": "12", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "1164", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Den Haag Centraal", + "shortValue": "richting Den Haag Centraal", + "accessibilityValue": "richting Den Haag Centraal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "notes": [ + { + "value": "SHORTER_TRAIN", + "accessibilityValue": "SHORTER_TRAIN", + "key": "trainSize", + "noteType": "UNKNOWN", + "isPresentationRequired": false + } + ], + "messages": [ + { + "text": "Kortere trein, extra druk", + "type": "SHORTENED", + "nesProperties": { + "color": "text-warning-contrast", + "type": "warning", + "icon": "alert" + } + } + ], + "transferMessages": [ + { + "message": "11 min. overstaptijd", + "accessibilityMessage": "11 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400206", + "uicCdCode": "118400206", + "name": "Eindhoven Centraal", + "lat": 51.4433326721191, + "lng": 5.48138904571533, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:14:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:14:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "5", + "plannedDepartureTrack": "5", + "plannedArrivalTrack": "5", + "actualArrivalTrack": "5", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400597", + "uicCdCode": "118400597", + "name": "Tilburg", + "lat": 51.5605545043945, + "lng": 5.08361101150513, + "countryCode": "NL", + "notes": [], + "routeIdx": 5, + "plannedDepartureDateTime": "2025-09-15T18:38:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:38:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T18:35:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:35:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "2", + "plannedDepartureTrack": "2", + "plannedArrivalTrack": "2", + "actualArrivalTrack": "2", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 9, + "plannedDepartureDateTime": "2025-09-15T18:53:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:53:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T18:51:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:51:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 15, + "plannedArrivalDateTime": "2025-09-15T19:15:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T19:15:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "12", + "plannedDepartureTrack": "12", + "plannedArrivalTrack": "12", + "actualArrivalTrack": "12", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "HIGH", + "punctuality": 83.3, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#441788#TA#0#DA#150925#1S#1100921#1T#1814#LS#1101078#LT#1941#PU#784#RT#1#CA#IC#ZE#1164#ZB#IC 1164 #PC#1#FR#1100921#FT#1814#TO#1101078#TT#1941#&train=1164&datetime=2025-09-15T18:14:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 61, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "1:01 u.", + "accessibilityValue": "1 uur en 1 minuut", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "distanceInMeters": 101727 + } + ], + "checksum": "b7d9a85e_3", + "crowdForecast": "HIGH", + "punctuality": 77.8, + "optimal": false, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1900, + "priceInCentsExcludingSupplement": 1900, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 1900, + "priceInCentsExcludingSupplement": 1900, + "buyableTicketPriceInCents": 1900, + "buyableTicketPriceInCentsExcludingSupplement": 1900, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1646/1915?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A46%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A15%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D3511802831" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A46%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A15%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D3511802831&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4b", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "5", + "accessibilityName": "Intercity" + } + ] + }, + { + "idx": 5, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:54:00+02:00|plannedArrivalTime=2025-09-15T19:10:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1826949240", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:54:00+02:00|plannedArrivalTime=2025-09-15T19:10:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1826949240", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100836@a=128@$A=1@O=Utrecht Centraal@L=1100728@a=128@$202509151654$202509151721$IC 3061 $$1$$$$$$§W$A=1@O=Utrecht Centraal@L=1100728@a=128@$A=1@O=Utrecht Centraal@L=1100905@a=128@$202509151721$202509151723$$$1$$$$$$§T$A=1@O=Utrecht Centraal@L=1100905@a=128@$A=1@O='s-Hertogenbosch@L=1100870@a=128@$202509151724$202509151752$IC 3561 $$3$$$$$$§W$A=1@O='s-Hertogenbosch@L=1100870@a=128@$A=1@O='s-Hertogenbosch@L=1101032@a=128@$202509151752$202509151756$$$1$$$$$$§T$A=1@O='s-Hertogenbosch@L=1101032@a=128@$A=1@O=Utrecht Centraal@L=1100715@a=128@$202509151800$202509151827$IC 2762 $$1$$$$$$§W$A=1@O=Utrecht Centraal@L=1100715@a=128@$A=1@O=Utrecht Centraal@L=1100672@a=128@$202509151827$202509151832$$$1$$$$$$§T$A=1@O=Utrecht Centraal@L=1100672@a=128@$A=1@O=Rotterdam Centraal@L=1100690@a=128@$202509151833$202509151910$IC 2862 $$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#45319#HIN#460#ECK#13985|13974|14107|14110|0|0|485|13955|6|0|8|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 136, + "actualDurationInMinutes": 136, + "transfers": 3, + "status": "NORMAL", + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 3061", + "travelType": "PUBLIC_TRANSIT", + "direction": "Nijmegen", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#444552#TA#0#DA#150925#1S#1101022#1T#1534#LS#1101149#LT#1817#PU#784#RT#1#CA#IC#ZE#3061#ZB#IC 3061 #PC#1#FR#1101022#FT#1534#TO#1101149#TT#1817#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:54:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:54:00+0200", + "plannedTrack": "4", + "actualTrack": "4", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:21:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:21:00+0200", + "plannedTrack": "19", + "actualTrack": "19", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3061", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Nijmegen", + "shortValue": "richting Nijmegen", + "accessibilityValue": "richting Nijmegen", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "1 tussenstop", + "shortValue": "1 tussenstop", + "accessibilityValue": "1 tussenstop", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T16:54:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:54:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T17:02:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:02:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:01:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:01:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedArrivalDateTime": "2025-09-15T17:21:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:21:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "19", + "plannedDepartureTrack": "19", + "plannedArrivalTrack": "19", + "actualArrivalTrack": "19", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 12, + "punctuality": 100.0, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#444552#TA#0#DA#150925#1S#1101022#1T#1534#LS#1101149#LT#1817#PU#784#RT#1#CA#IC#ZE#3061#ZB#IC 3061 #PC#1#FR#1101022#FT#1534#TO#1101149#TT#1817#&train=3061&datetime=2025-09-15T16:54:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 27, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "27 min.", + "accessibilityValue": "27 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 37529 + }, + { + "idx": "1", + "name": "IC 3561", + "travelType": "PUBLIC_TRANSIT", + "direction": "Venlo", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#507294#TA#0#DA#150925#1S#1101068#1T#1534#LS#1100958#LT#1858#PU#784#RT#3#CA#IC#ZE#3561#ZB#IC 3561 #PC#1#FR#1101068#FT#1534#TO#1100958#TT#1858#", + "origin": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:24:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:24:00+0200", + "plannedTrack": "18", + "actualTrack": "18", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:52:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:52:00+0200", + "plannedTrack": "6", + "actualTrack": "6", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3561", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Venlo", + "shortValue": "richting Venlo", + "accessibilityValue": "richting Venlo", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "3 min. overstaptijd", + "accessibilityMessage": "3 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T17:24:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:24:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "18", + "plannedDepartureTrack": "18", + "plannedArrivalTrack": "18", + "actualArrivalTrack": "18", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 8, + "plannedArrivalDateTime": "2025-09-15T17:52:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:52:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "HIGH", + "bicycleSpotCount": 12, + "punctuality": 90.0, + "crossPlatformTransfer": false, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#507294#TA#0#DA#150925#1S#1101068#1T#1534#LS#1100958#LT#1858#PU#784#RT#3#CA#IC#ZE#3561#ZB#IC 3561 #PC#1#FR#1101068#FT#1534#TO#1100958#TT#1858#&train=3561&datetime=2025-09-15T17:24:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 28, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "28 min.", + "accessibilityValue": "28 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 4, + "distanceInMeters": 47266 + }, + { + "idx": "2", + "name": "IC 2762", + "travelType": "PUBLIC_TRANSIT", + "direction": "Alkmaar", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#443724#TA#0#DA#150925#1S#1101011#1T#1629#LS#1101016#LT#1933#PU#784#RT#1#CA#IC#ZE#2762#ZB#IC 2762 #PC#1#FR#1101011#FT#1629#TO#1101016#TT#1933#", + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:00:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:00:00+0200", + "plannedTrack": "3", + "actualTrack": "3", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:27:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:27:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2762", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Alkmaar", + "shortValue": "richting Alkmaar", + "accessibilityValue": "richting Alkmaar", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "8 min. overstaptijd", + "accessibilityMessage": "8 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:00:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:00:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "3", + "plannedDepartureTrack": "3", + "plannedArrivalTrack": "3", + "actualArrivalTrack": "3", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 8, + "plannedArrivalDateTime": "2025-09-15T18:27:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:27:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 6, + "crossPlatformTransfer": false, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#443724#TA#0#DA#150925#1S#1101011#1T#1629#LS#1101016#LT#1933#PU#784#RT#1#CA#IC#ZE#2762#ZB#IC 2762 #PC#1#FR#1101011#FT#1629#TO#1101016#TT#1933#&train=2762&datetime=2025-09-15T18:00:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 27, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "27 min.", + "accessibilityValue": "27 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 5, + "distanceInMeters": 47266 + }, + { + "idx": "3", + "name": "IC 2862", + "travelType": "PUBLIC_TRANSIT", + "direction": "Rotterdam Centraal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1137#TA#2#DA#150925#1S#1100672#1T#1833#LS#1100690#LT#1910#PU#784#RT#1#CA#IC#ZE#2862#ZB#IC 2862 #PC#1#FR#1100672#FT#1833#TO#1100690#TT#1910#", + "origin": { + "name": "Utrecht Centraal", + "lng": 5.11027765274048, + "lat": 52.0888900756836, + "countryCode": "NL", + "uicCode": "8400621", + "uicCdCode": "118400621", + "stationCode": "UT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:33:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:33:00+0200", + "plannedTrack": "11", + "actualTrack": "11", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T19:10:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T19:10:00+0200", + "plannedTrack": "14", + "actualTrack": "14", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2862", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Rotterdam Centraal", + "shortValue": "richting Rotterdam Centraal", + "accessibilityValue": "richting Rotterdam Centraal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "6 min. overstaptijd", + "accessibilityMessage": "6 minuten overstaptijd", + "type": "TRANSFER_TIME", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:33:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:33:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "11", + "plannedDepartureTrack": "11", + "plannedArrivalTrack": "11", + "actualArrivalTrack": "11", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400258", + "uicCdCode": "118400258", + "name": "Gouda", + "lat": 52.0175018310547, + "lng": 4.70444440841675, + "countryCode": "NL", + "notes": [], + "routeIdx": 6, + "plannedDepartureDateTime": "2025-09-15T18:52:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:52:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T18:51:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:51:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "8", + "plannedDepartureTrack": "8", + "plannedArrivalTrack": "8", + "actualArrivalTrack": "8", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400507", + "uicCdCode": "118400507", + "name": "Rotterdam Alexander", + "lat": 51.9519462585449, + "lng": 4.55361127853394, + "countryCode": "NL", + "notes": [], + "routeIdx": 9, + "plannedDepartureDateTime": "2025-09-15T19:01:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T19:01:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T19:01:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T19:01:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "2", + "plannedDepartureTrack": "2", + "plannedArrivalTrack": "2", + "actualArrivalTrack": "2", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 11, + "plannedArrivalDateTime": "2025-09-15T19:10:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T19:10:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "14", + "plannedDepartureTrack": "14", + "plannedArrivalTrack": "14", + "actualArrivalTrack": "14", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 6, + "punctuality": 88.9, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1137#TA#2#DA#150925#1S#1100672#1T#1833#LS#1100690#LT#1910#PU#784#RT#1#CA#IC#ZE#2862#ZB#IC 2862 #PC#1#FR#1100672#FT#1833#TO#1100690#TT#1910#&train=2862&datetime=2025-09-15T18:33:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 37, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "37 min.", + "accessibilityValue": "37 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "distanceInMeters": 50987 + } + ], + "checksum": "bf4e4bd4_3", + "crowdForecast": "HIGH", + "punctuality": 88.9, + "optimal": false, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1910, + "priceInCentsExcludingSupplement": 1910, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + }, + { + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 2010, + "priceInCentsExcludingSupplement": 2010, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 3920, + "priceInCentsExcludingSupplement": 3920, + "buyableTicketPriceInCents": 3920, + "buyableTicketPriceInCentsExcludingSupplement": 3920, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1654/1910?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A54%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A10%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1826949240" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A54%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A10%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1826949240&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "18", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "3", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "11", + "accessibilityName": "Intercity" + } + ] + }, + { + "idx": 6, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T17:05:00+02:00|plannedArrivalTime=2025-09-15T19:07:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1121055257", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T17:05:00+02:00|plannedArrivalTime=2025-09-15T19:07:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1121055257", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100836@a=128@$A=1@O='s-Hertogenbosch@L=1100870@a=128@$202509151705$202509151802$IC 2763 $$1$$$$$$§W$A=1@O='s-Hertogenbosch@L=1100870@a=128@$A=1@O='s-Hertogenbosch@L=1101751@a=128@$202509151802$202509151804$$$1$$$$$$§T$A=1@O='s-Hertogenbosch@L=1101751@a=128@$A=1@O=Breda@L=1101034@a=128@$202509151811$202509151840$IC 3663 $$3$$$$$$§W$A=1@O=Breda@L=1101034@a=128@$A=1@O=Breda@L=1100942@a=128@$202509151840$202509151842$$$1$$$$$$§T$A=1@O=Breda@L=1100942@a=128@$A=1@O=Rotterdam Centraal@L=1100726@a=128@$202509151844$202509151907$ICD 1873$$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#261#HIN#390#ECK#13985|13985|14107|14107|0|0|66021|13955|7|0|2|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 122, + "actualDurationInMinutes": 122, + "transfers": 2, + "status": "NORMAL", + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 2763", + "travelType": "PUBLIC_TRANSIT", + "direction": "Maastricht", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1090#TA#0#DA#150925#1S#1101009#1T#1627#LS#1101011#LT#1933#PU#784#RT#1#CA#IC#ZE#2763#ZB#IC 2763 #PC#1#FR#1101009#FT#1627#TO#1101011#TT#1933#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:05:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:05:00+0200", + "plannedTrack": "4", + "actualTrack": "4", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:02:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:02:00+0200", + "plannedTrack": "6", + "actualTrack": "6", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2763", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Maastricht", + "shortValue": "richting Maastricht", + "accessibilityValue": "richting Maastricht", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T17:05:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:05:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T17:13:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:13:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:13:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:13:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedDepartureDateTime": "2025-09-15T17:34:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:34:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:31:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:31:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "18", + "plannedDepartureTrack": "18", + "plannedArrivalTrack": "18", + "actualArrivalTrack": "18", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 18, + "plannedArrivalDateTime": "2025-09-15T18:02:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:02:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 12, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1090#TA#0#DA#150925#1S#1101009#1T#1627#LS#1101011#LT#1933#PU#784#RT#1#CA#IC#ZE#2763#ZB#IC 2763 #PC#1#FR#1101009#FT#1627#TO#1101011#TT#1933#&train=2763&datetime=2025-09-15T17:05:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 57, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "57 min.", + "accessibilityValue": "57 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 84795 + }, + { + "idx": "1", + "name": "IC 3663", + "travelType": "PUBLIC_TRANSIT", + "direction": "Roosendaal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505598#TA#0#DA#150925#1S#1101167#1T#1620#LS#1101102#LT#1903#PU#784#RT#3#CA#IC#ZE#3663#ZB#IC 3663 #PC#1#FR#1101167#FT#1620#TO#1101102#TT#1903#", + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:11:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:11:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:40:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:40:00+0200", + "plannedTrack": "8", + "actualTrack": "8", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3663", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Roosendaal", + "shortValue": "richting Roosendaal", + "accessibilityValue": "richting Roosendaal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "1 tussenstop", + "shortValue": "1 tussenstop", + "accessibilityValue": "1 tussenstop", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:11:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:11:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400597", + "uicCdCode": "118400597", + "name": "Tilburg", + "lat": 51.5605545043945, + "lng": 5.08361101150513, + "countryCode": "NL", + "notes": [], + "routeIdx": 1, + "plannedDepartureDateTime": "2025-09-15T18:28:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:28:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T18:26:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:26:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "3", + "plannedDepartureTrack": "3", + "plannedArrivalTrack": "3", + "actualArrivalTrack": "3", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 5, + "plannedArrivalDateTime": "2025-09-15T18:40:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:40:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "8", + "plannedDepartureTrack": "8", + "plannedArrivalTrack": "8", + "actualArrivalTrack": "8", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "bicycleSpotCount": 6, + "punctuality": 100.0, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505598#TA#0#DA#150925#1S#1101167#1T#1620#LS#1101102#LT#1903#PU#784#RT#3#CA#IC#ZE#3663#ZB#IC 3663 #PC#1#FR#1101167#FT#1620#TO#1101102#TT#1903#&train=3663&datetime=2025-09-15T18:11:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 29, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "29 min.", + "accessibilityValue": "29 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 41871 + }, + { + "idx": "2", + "name": "ICD 1873", + "travelType": "PUBLIC_TRANSIT", + "direction": "Amersfoort Schothorst", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#442518#TA#0#DA#150925#1S#1100942#1T#1844#LS#1100687#LT#2024#PU#784#RT#1#CA#ICD#ZE#1873#ZB#ICD 1873#PC#1#FR#1100942#FT#1844#TO#1100687#TT#2024#", + "origin": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:44:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:44:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T19:07:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T19:07:00+0200", + "plannedTrack": "12", + "actualTrack": "12", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "1873", + "categoryCode": "ICD", + "shortCategoryName": "ICD", + "longCategoryName": "Intercity direct", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity direct", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity direct", + "shortValue": "NS Intercity direct", + "accessibilityValue": "NS Intercity direct", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Amersfoort Schothorst via Schiphol Airport", + "shortValue": "richting Amersfoort Schothorst via Schiphol Airport", + "accessibilityValue": "richting Amersfoort Schothorst via Schiphol Airport", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:44:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:44:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 6, + "plannedArrivalDateTime": "2025-09-15T19:07:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T19:07:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "12", + "plannedDepartureTrack": "12", + "plannedArrivalTrack": "12", + "actualArrivalTrack": "12", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 16, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#442518#TA#0#DA#150925#1S#1100942#1T#1844#LS#1100687#LT#2024#PU#784#RT#1#CA#ICD#ZE#1873#ZB#ICD 1873#PC#1#FR#1100942#FT#1844#TO#1100687#TT#2024#&train=1873&datetime=2025-09-15T18:44:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 23, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "23 min.", + "accessibilityValue": "23 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "recognizableDestination": "Schiphol Airport", + "distanceInMeters": 44166 + } + ], + "checksum": "78085762_3", + "crowdForecast": "MEDIUM", + "punctuality": 100.0, + "optimal": false, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1910, + "priceInCentsExcludingSupplement": 1910, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + }, + { + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 2010, + "priceInCentsExcludingSupplement": 2010, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 3920, + "priceInCentsExcludingSupplement": 3920, + "buyableTicketPriceInCents": 3920, + "buyableTicketPriceInCentsExcludingSupplement": 3920, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1705/1907?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T17%3A05%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A07%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1121055257" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T17%3A05%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A07%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1121055257&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity direct", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity direct" + } + ] + }, + { + "idx": 7, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T17:05:00+02:00|plannedArrivalTime=2025-09-15T19:15:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=459261293", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T17:05:00+02:00|plannedArrivalTime=2025-09-15T19:15:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=459261293", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100836@a=128@$A=1@O='s-Hertogenbosch@L=1100870@a=128@$202509151705$202509151802$IC 2763 $$1$$$$$$§W$A=1@O='s-Hertogenbosch@L=1100870@a=128@$A=1@O='s-Hertogenbosch@L=1101751@a=128@$202509151802$202509151804$$$1$$$$$$§T$A=1@O='s-Hertogenbosch@L=1101751@a=128@$A=1@O=Breda@L=1101034@a=128@$202509151811$202509151840$IC 3663 $$3$$$$$$§W$A=1@O=Breda@L=1101034@a=128@$A=1@O=Breda@L=1100942@a=128@$202509151840$202509151842$$$1$$$$$$§T$A=1@O=Breda@L=1100942@a=128@$A=1@O=Rotterdam Centraal@L=1100726@a=128@$202509151853$202509151915$IC 1164 $$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#45317#HIN#390#ECK#13985|13985|14107|14115|0|0|485|13955|8|0|8|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 130, + "actualDurationInMinutes": 130, + "transfers": 2, + "status": "NORMAL", + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 2763", + "travelType": "PUBLIC_TRANSIT", + "direction": "Maastricht", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1090#TA#0#DA#150925#1S#1101009#1T#1627#LS#1101011#LT#1933#PU#784#RT#1#CA#IC#ZE#2763#ZB#IC 2763 #PC#1#FR#1101009#FT#1627#TO#1101011#TT#1933#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:05:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:05:00+0200", + "plannedTrack": "4", + "actualTrack": "4", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:02:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:02:00+0200", + "plannedTrack": "6", + "actualTrack": "6", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2763", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Maastricht", + "shortValue": "richting Maastricht", + "accessibilityValue": "richting Maastricht", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T17:05:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:05:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T17:13:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:13:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:13:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:13:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedDepartureDateTime": "2025-09-15T17:34:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:34:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:31:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:31:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "18", + "plannedDepartureTrack": "18", + "plannedArrivalTrack": "18", + "actualArrivalTrack": "18", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 18, + "plannedArrivalDateTime": "2025-09-15T18:02:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:02:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 12, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1090#TA#0#DA#150925#1S#1101009#1T#1627#LS#1101011#LT#1933#PU#784#RT#1#CA#IC#ZE#2763#ZB#IC 2763 #PC#1#FR#1101009#FT#1627#TO#1101011#TT#1933#&train=2763&datetime=2025-09-15T17:05:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 57, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "57 min.", + "accessibilityValue": "57 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 84795 + }, + { + "idx": "1", + "name": "IC 3663", + "travelType": "PUBLIC_TRANSIT", + "direction": "Roosendaal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505598#TA#0#DA#150925#1S#1101167#1T#1620#LS#1101102#LT#1903#PU#784#RT#3#CA#IC#ZE#3663#ZB#IC 3663 #PC#1#FR#1101167#FT#1620#TO#1101102#TT#1903#", + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:11:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:11:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:40:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:40:00+0200", + "plannedTrack": "8", + "actualTrack": "8", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3663", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Roosendaal", + "shortValue": "richting Roosendaal", + "accessibilityValue": "richting Roosendaal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "1 tussenstop", + "shortValue": "1 tussenstop", + "accessibilityValue": "1 tussenstop", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:11:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:11:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400597", + "uicCdCode": "118400597", + "name": "Tilburg", + "lat": 51.5605545043945, + "lng": 5.08361101150513, + "countryCode": "NL", + "notes": [], + "routeIdx": 1, + "plannedDepartureDateTime": "2025-09-15T18:28:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:28:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T18:26:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:26:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "3", + "plannedDepartureTrack": "3", + "plannedArrivalTrack": "3", + "actualArrivalTrack": "3", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 5, + "plannedArrivalDateTime": "2025-09-15T18:40:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:40:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "8", + "plannedDepartureTrack": "8", + "plannedArrivalTrack": "8", + "actualArrivalTrack": "8", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "bicycleSpotCount": 6, + "punctuality": 100.0, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505598#TA#0#DA#150925#1S#1101167#1T#1620#LS#1101102#LT#1903#PU#784#RT#3#CA#IC#ZE#3663#ZB#IC 3663 #PC#1#FR#1101167#FT#1620#TO#1101102#TT#1903#&train=3663&datetime=2025-09-15T18:11:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 29, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "29 min.", + "accessibilityValue": "29 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 41871 + }, + { + "idx": "2", + "name": "IC 1164", + "travelType": "PUBLIC_TRANSIT", + "direction": "Den Haag Centraal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#441788#TA#0#DA#150925#1S#1100921#1T#1814#LS#1101078#LT#1941#PU#784#RT#1#CA#IC#ZE#1164#ZB#IC 1164 #PC#1#FR#1100921#FT#1814#TO#1101078#TT#1941#", + "origin": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:53:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:53:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T19:15:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T19:15:00+0200", + "plannedTrack": "12", + "actualTrack": "12", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "1164", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Den Haag Centraal", + "shortValue": "richting Den Haag Centraal", + "accessibilityValue": "richting Den Haag Centraal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:53:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:53:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 6, + "plannedArrivalDateTime": "2025-09-15T19:15:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T19:15:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "12", + "plannedDepartureTrack": "12", + "plannedArrivalTrack": "12", + "actualArrivalTrack": "12", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "punctuality": 83.3, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#441788#TA#0#DA#150925#1S#1100921#1T#1814#LS#1101078#LT#1941#PU#784#RT#1#CA#IC#ZE#1164#ZB#IC 1164 #PC#1#FR#1100921#FT#1814#TO#1101078#TT#1941#&train=1164&datetime=2025-09-15T18:53:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 22, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "22 min.", + "accessibilityValue": "22 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "distanceInMeters": 44166 + } + ], + "checksum": "16db0b8e_3", + "crowdForecast": "MEDIUM", + "punctuality": 83.3, + "optimal": false, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1910, + "priceInCentsExcludingSupplement": 1910, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + }, + { + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 2010, + "priceInCentsExcludingSupplement": 2010, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 3920, + "priceInCentsExcludingSupplement": 3920, + "buyableTicketPriceInCents": 3920, + "buyableTicketPriceInCentsExcludingSupplement": 3920, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1705/1915?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T17%3A05%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A15%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D459261293" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T17%3A05%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T19%3A15%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D459261293&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity" + } + ] + } + ], + "scrollRequestBackwardContext": "3|OB|MTµ14µ13954µ13944µ14077µ14080µ0µ0µ485µ13938µ1µ0µ8µ0µ0µ-2147483648µ1µ2|PDHµ28839c70675e70c3d48018993723bca2|RDµ15092025|RTµ161800|USµ0|RSµINIT", + "scrollRequestForwardContext": "3|OF|MTµ14µ13985µ13985µ14107µ14115µ0µ0µ485µ13955µ8µ0µ8µ0µ0µ-2147483648µ1µ2|PDHµ28839c70675e70c3d48018993723bca2|RDµ15092025|RTµ161800|USµ0|RSµINIT" +} diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..27e2cbb0147 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'arrival_delay': False, + 'arrival_platform_actual': '12', + 'arrival_platform_planned': '12', + 'arrival_time_actual': '18:37', + 'arrival_time_planned': '18:37', + 'attribution': 'Data provided by NS', + 'departure_delay': True, + 'departure_platform_actual': '4', + 'departure_platform_planned': '4', + 'departure_time_actual': '16:35', + 'departure_time_planned': '16:34', + 'friendly_name': 'To work', + 'going': True, + 'icon': 'mdi:train', + 'next': '16:46', + 'remarks': None, + 'route': list([ + 'Amsterdam Centraal', + "'s-Hertogenbosch", + 'Breda', + 'Rotterdam Centraal', + ]), + 'status': 'normal', + 'transfers': 2, + }), + 'context': , + 'entity_id': 'sensor.to_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16:35', + }) +# --- diff --git a/tests/components/nederlandse_spoorwegen/test_config_flow.py b/tests/components/nederlandse_spoorwegen/test_config_flow.py new file mode 100644 index 00000000000..8d0c8e2b451 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/test_config_flow.py @@ -0,0 +1,333 @@ +"""Test config flow for Nederlandse Spoorwegen integration.""" + +from datetime import time +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from requests import ConnectionError as RequestsConnectionError, HTTPError, Timeout + +from homeassistant.components.nederlandse_spoorwegen.const import ( + CONF_FROM, + CONF_ROUTES, + CONF_TIME, + CONF_TO, + CONF_VIA, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import API_KEY + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_nsapi: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: API_KEY} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nederlandse Spoorwegen" + assert result["data"] == {CONF_API_KEY: API_KEY} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_creating_route( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a route after setting up the main config entry.""" + mock_config_entry.add_to_hass(hass) + assert len(mock_config_entry.subentries) == 1 + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "route"), context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_NAME: "Home to Work", + CONF_TIME: "08:30", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Home to Work" + assert result["data"] == { + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_NAME: "Home to Work", + CONF_TIME: "08:30", + } + assert len(mock_config_entry.subentries) == 2 + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (HTTPError("Invalid API key"), "invalid_auth"), + (Timeout("Cannot connect"), "cannot_connect"), + (RequestsConnectionError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_flow_exceptions( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test config flow handling different exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_nsapi.get_stations.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: API_KEY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + mock_nsapi.get_stations.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: API_KEY} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nederlandse Spoorwegen" + assert result["data"] == {CONF_API_KEY: API_KEY} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_fetching_stations_failed( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a route after setting up the main config entry.""" + mock_config_entry.add_to_hass(hass) + assert len(mock_config_entry.subentries) == 1 + mock_nsapi.get_stations.side_effect = RequestsConnectionError("Unexpected error") + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "route"), context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config flow aborts if already configured.""" + 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["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_config_flow_import_success( + hass: HomeAssistant, mock_nsapi: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test successful import flow from YAML configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: API_KEY}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nederlandse Spoorwegen" + assert result["data"] == {CONF_API_KEY: API_KEY} + assert not result["result"].subentries + + +@pytest.mark.parametrize( + ("routes_data", "expected_routes_data"), + [ + ( + # Test with uppercase station codes (UI behavior) + [ + { + CONF_NAME: "Home to Work", + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_TIME: time(hour=8, minute=30), + } + ], + [ + { + CONF_NAME: "Home to Work", + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_TIME: time(hour=8, minute=30), + } + ], + ), + ( + # Test with lowercase station codes (converted to uppercase) + [ + { + CONF_NAME: "Rotterdam-Amsterdam", + CONF_FROM: "rtd", # lowercase input + CONF_TO: "asd", # lowercase input + }, + { + CONF_NAME: "Amsterdam-Haarlem", + CONF_FROM: "asd", # lowercase input + CONF_TO: "ht", # lowercase input + CONF_VIA: "rtd", # lowercase input + }, + ], + [ + { + CONF_NAME: "Rotterdam-Amsterdam", + CONF_FROM: "RTD", # converted to uppercase + CONF_TO: "ASD", # converted to uppercase + }, + { + CONF_NAME: "Amsterdam-Haarlem", + CONF_FROM: "ASD", # converted to uppercase + CONF_TO: "HT", # converted to uppercase + CONF_VIA: "RTD", # converted to uppercase + }, + ], + ), + ], +) +async def test_config_flow_import_with_routes( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_setup_entry: AsyncMock, + routes_data: list[dict[str, Any]], + expected_routes_data: list[dict[str, Any]], +) -> None: + """Test import flow with routes from YAML configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_API_KEY: API_KEY, + CONF_ROUTES: routes_data, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nederlandse Spoorwegen" + assert result["data"] == {CONF_API_KEY: API_KEY} + assert len(result["result"].subentries) == len(expected_routes_data) + + subentries = list(result["result"].subentries.values()) + for expected_route in expected_routes_data: + route_entry = next( + entry for entry in subentries if entry.title == expected_route[CONF_NAME] + ) + assert route_entry.data == expected_route + assert route_entry.subentry_type == "route" + + +async def test_config_flow_import_with_unknown_station( + hass: HomeAssistant, mock_nsapi: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test import flow aborts with unknown station in routes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_API_KEY: API_KEY, + CONF_ROUTES: [ + { + CONF_NAME: "Home to Work", + CONF_FROM: "HRM", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_TIME: time(hour=8, minute=30), + } + ], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_station" + + +async def test_config_flow_import_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test import flow when integration is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: API_KEY}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (HTTPError("Invalid API key"), "invalid_auth"), + (Timeout("Cannot connect"), "cannot_connect"), + (RequestsConnectionError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_import_flow_exceptions( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test config flow handling different exceptions.""" + mock_nsapi.get_stations.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_API_KEY: API_KEY} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error diff --git a/tests/components/nederlandse_spoorwegen/test_sensor.py b/tests/components/nederlandse_spoorwegen/test_sensor.py new file mode 100644 index 00000000000..8c90b0f96ce --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/test_sensor.py @@ -0,0 +1,72 @@ +"""Test the Nederlandse Spoorwegen sensor.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nederlandse_spoorwegen.const import ( + CONF_FROM, + CONF_ROUTES, + CONF_TO, + CONF_VIA, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import API_KEY + +from tests.common import MockConfigEntry + + +async def test_config_import( + hass: HomeAssistant, + mock_nsapi, + mock_setup_entry: AsyncMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test sensor initialization.""" + await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: [ + { + CONF_PLATFORM: DOMAIN, + CONF_API_KEY: API_KEY, + CONF_ROUTES: [ + { + CONF_NAME: "Spoorwegen Nederlande Station", + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + } + ], + } + ] + }, + ) + + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml") in issue_registry.issues + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00") +async def test_sensor( + hass: HomeAssistant, + mock_nsapi, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor initialization.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.to_work") == snapshot diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 1a5c4d63dba..ab060160525 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -71,9 +71,9 @@ async def test_auth( # Verify API requests are made with the correct credentials calls = aioclient_mock.mock_calls assert len(calls) == 2 - (method, url, data, headers) = calls[0] + (_method, _url, _data, headers) = calls[0] assert headers == {"Authorization": f"Bearer {FAKE_TOKEN}"} - (method, url, data, headers) = calls[1] + (_method, _url, _data, headers) = calls[1] assert headers == {"Authorization": f"Bearer {FAKE_TOKEN}"} # Verify the subscriber was created with the correct credentials diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index 755893adb11..6279f3ff429 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -4,10 +4,10 @@ import ast import pytest +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import ( DOMAIN as MS_DOMAIN, URI_SCHEME, - BrowseError, PlayMedia, async_browse_media, async_resolve_media, diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 85e932f8018..039113892c1 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -124,7 +124,7 @@ async def test_active_accessory( snapshot: SnapshotAssertion, ) -> None: """Test climate groups that can be deactivated by configuration.""" - climate, unit = _setup_climate_group(coils, model, climate_id) + climate, _unit = _setup_climate_group(coils, model, climate_id) await async_add_model(hass, model) diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py index 35260b387de..330488727bb 100644 --- a/tests/components/niko_home_control/conftest.py +++ b/tests/components/niko_home_control/conftest.py @@ -79,6 +79,7 @@ def mock_niko_home_control_connection( client = mock_client.return_value client.lights = [light, dimmable_light] client.covers = [cover] + client.connect = AsyncMock(return_value=True) yield client diff --git a/tests/components/niko_home_control/test_config_flow.py b/tests/components/niko_home_control/test_config_flow.py index 2878dc91138..41f9a3dcf9e 100644 --- a/tests/components/niko_home_control/test_config_flow.py +++ b/tests/components/niko_home_control/test_config_flow.py @@ -36,7 +36,7 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 -async def test_cannot_connect(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_cannot_connect(hass: HomeAssistant) -> None: """Test the cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -69,7 +69,7 @@ async def test_cannot_connect(hass: HomeAssistant, mock_setup_entry: AsyncMock) async def test_duplicate_entry( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test uniqueness.""" @@ -88,3 +88,83 @@ async def test_duplicate_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_duplicate_reconfigure_entry( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure to other existing entry.""" + mock_config_entry.add_to_hass(hass) + another_entry = MockConfigEntry( + domain=DOMAIN, + title="Niko Home Control", + data={CONF_HOST: "192.168.0.124"}, + entry_id="01JFN93M7KRA38V5AMPCJ2JYYB", + ) + another_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.0.124"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert set(result["data_schema"].schema) == {CONF_HOST} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.122"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration with connection error.""" + mock_config_entry.add_to_hass(hass) + + mock_niko_home_control_connection.connect.side_effect = Exception("cannot_connect") + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.122"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_niko_home_control_connection.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.122"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 5c0548c4158..3fa366216fd 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -213,7 +213,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOSTS: "192.168.1.0/24", CONF_CONSIDER_HOME: 180, CONF_SCAN_INTERVAL: 120, - CONF_OPTIONS: "-F -T4 --min-rate 10 --host-timeout 5s", + CONF_OPTIONS: "-n -sn -PR -T4 --min-rate 10 --host-timeout 5s", } with patch( diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index ca1e2a05a0b..2f5318d515c 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -47,7 +47,7 @@ async def get_data_from_library( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-05", + "date": "2025-10-01", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", @@ -58,7 +58,7 @@ async def get_data_from_library( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-05", + "date": "2025-10-01", "market": "DayAhead", "deliveryArea": "SE3", "currency": "EUR", @@ -69,7 +69,7 @@ async def get_data_from_library( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-04", + "date": "2025-09-30", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", @@ -80,7 +80,7 @@ async def get_data_from_library( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-06", + "date": "2025-10-02", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", diff --git a/tests/components/nordpool/fixtures/delivery_period_nl.json b/tests/components/nordpool/fixtures/delivery_period_nl.json index cd326e05d01..2c99e5614a2 100644 --- a/tests/components/nordpool/fixtures/delivery_period_nl.json +++ b/tests/components/nordpool/fixtures/delivery_period_nl.json @@ -1,213 +1,717 @@ { - "deliveryDateCET": "2024-11-05", + "deliveryDateCET": "2025-10-01", "version": 2, - "updatedAt": "2024-11-04T11:58:10.7711584Z", + "updatedAt": "2025-09-30T11:08:13.1885499Z", "deliveryAreas": ["NL"], "market": "DayAhead", "multiAreaEntries": [ { - "deliveryStart": "2024-11-04T23:00:00Z", - "deliveryEnd": "2024-11-05T00:00:00Z", + "deliveryStart": "2025-09-30T22:00:00Z", + "deliveryEnd": "2025-09-30T22:15:00Z", "entryPerArea": { - "NL": 83.63 + "NL": 102.55 } }, { - "deliveryStart": "2024-11-05T00:00:00Z", - "deliveryEnd": "2024-11-05T01:00:00Z", + "deliveryStart": "2025-09-30T22:15:00Z", + "deliveryEnd": "2025-09-30T22:30:00Z", "entryPerArea": { - "NL": 94.0 + "NL": 92.17 } }, { - "deliveryStart": "2024-11-05T01:00:00Z", - "deliveryEnd": "2024-11-05T02:00:00Z", + "deliveryStart": "2025-09-30T22:30:00Z", + "deliveryEnd": "2025-09-30T22:45:00Z", "entryPerArea": { - "NL": 90.68 + "NL": 82.69 } }, { - "deliveryStart": "2024-11-05T02:00:00Z", - "deliveryEnd": "2024-11-05T03:00:00Z", + "deliveryStart": "2025-09-30T22:45:00Z", + "deliveryEnd": "2025-09-30T23:00:00Z", "entryPerArea": { - "NL": 91.3 + "NL": 81.86 } }, { - "deliveryStart": "2024-11-05T03:00:00Z", - "deliveryEnd": "2024-11-05T04:00:00Z", + "deliveryStart": "2025-09-30T23:00:00Z", + "deliveryEnd": "2025-09-30T23:15:00Z", "entryPerArea": { - "NL": 94.0 + "NL": 89.54 } }, { - "deliveryStart": "2024-11-05T04:00:00Z", - "deliveryEnd": "2024-11-05T05:00:00Z", + "deliveryStart": "2025-09-30T23:15:00Z", + "deliveryEnd": "2025-09-30T23:30:00Z", "entryPerArea": { - "NL": 96.09 + "NL": 84.93 } }, { - "deliveryStart": "2024-11-05T05:00:00Z", - "deliveryEnd": "2024-11-05T06:00:00Z", + "deliveryStart": "2025-09-30T23:30:00Z", + "deliveryEnd": "2025-09-30T23:45:00Z", "entryPerArea": { - "NL": 106.0 + "NL": 83.56 } }, { - "deliveryStart": "2024-11-05T06:00:00Z", - "deliveryEnd": "2024-11-05T07:00:00Z", + "deliveryStart": "2025-09-30T23:45:00Z", + "deliveryEnd": "2025-10-01T00:00:00Z", "entryPerArea": { - "NL": 135.99 + "NL": 81.69 } }, { - "deliveryStart": "2024-11-05T07:00:00Z", - "deliveryEnd": "2024-11-05T08:00:00Z", + "deliveryStart": "2025-10-01T00:00:00Z", + "deliveryEnd": "2025-10-01T00:15:00Z", "entryPerArea": { - "NL": 136.21 + "NL": 81.87 } }, { - "deliveryStart": "2024-11-05T08:00:00Z", - "deliveryEnd": "2024-11-05T09:00:00Z", + "deliveryStart": "2025-10-01T00:15:00Z", + "deliveryEnd": "2025-10-01T00:30:00Z", "entryPerArea": { - "NL": 118.23 + "NL": 81.51 } }, { - "deliveryStart": "2024-11-05T09:00:00Z", - "deliveryEnd": "2024-11-05T10:00:00Z", + "deliveryStart": "2025-10-01T00:30:00Z", + "deliveryEnd": "2025-10-01T00:45:00Z", "entryPerArea": { - "NL": 105.87 + "NL": 77.42 } }, { - "deliveryStart": "2024-11-05T10:00:00Z", - "deliveryEnd": "2024-11-05T11:00:00Z", + "deliveryStart": "2025-10-01T00:45:00Z", + "deliveryEnd": "2025-10-01T01:00:00Z", "entryPerArea": { - "NL": 95.28 + "NL": 76.45 } }, { - "deliveryStart": "2024-11-05T11:00:00Z", - "deliveryEnd": "2024-11-05T12:00:00Z", + "deliveryStart": "2025-10-01T01:00:00Z", + "deliveryEnd": "2025-10-01T01:15:00Z", "entryPerArea": { - "NL": 94.92 + "NL": 79.32 } }, { - "deliveryStart": "2024-11-05T12:00:00Z", - "deliveryEnd": "2024-11-05T13:00:00Z", + "deliveryStart": "2025-10-01T01:15:00Z", + "deliveryEnd": "2025-10-01T01:30:00Z", "entryPerArea": { - "NL": 99.25 + "NL": 79.24 } }, { - "deliveryStart": "2024-11-05T13:00:00Z", - "deliveryEnd": "2024-11-05T14:00:00Z", + "deliveryStart": "2025-10-01T01:30:00Z", + "deliveryEnd": "2025-10-01T01:45:00Z", "entryPerArea": { - "NL": 107.98 + "NL": 80.05 } }, { - "deliveryStart": "2024-11-05T14:00:00Z", - "deliveryEnd": "2024-11-05T15:00:00Z", + "deliveryStart": "2025-10-01T01:45:00Z", + "deliveryEnd": "2025-10-01T02:00:00Z", "entryPerArea": { - "NL": 149.86 + "NL": 79.52 } }, { - "deliveryStart": "2024-11-05T15:00:00Z", - "deliveryEnd": "2024-11-05T16:00:00Z", + "deliveryStart": "2025-10-01T02:00:00Z", + "deliveryEnd": "2025-10-01T02:15:00Z", "entryPerArea": { - "NL": 303.24 + "NL": 79.94 } }, { - "deliveryStart": "2024-11-05T16:00:00Z", - "deliveryEnd": "2024-11-05T17:00:00Z", + "deliveryStart": "2025-10-01T02:15:00Z", + "deliveryEnd": "2025-10-01T02:30:00Z", "entryPerArea": { - "NL": 472.99 + "NL": 85.02 } }, { - "deliveryStart": "2024-11-05T17:00:00Z", - "deliveryEnd": "2024-11-05T18:00:00Z", + "deliveryStart": "2025-10-01T02:30:00Z", + "deliveryEnd": "2025-10-01T02:45:00Z", "entryPerArea": { - "NL": 431.02 + "NL": 83.89 } }, { - "deliveryStart": "2024-11-05T18:00:00Z", - "deliveryEnd": "2024-11-05T19:00:00Z", + "deliveryStart": "2025-10-01T02:45:00Z", + "deliveryEnd": "2025-10-01T03:00:00Z", "entryPerArea": { - "NL": 320.33 + "NL": 75.83 } }, { - "deliveryStart": "2024-11-05T19:00:00Z", - "deliveryEnd": "2024-11-05T20:00:00Z", + "deliveryStart": "2025-10-01T03:00:00Z", + "deliveryEnd": "2025-10-01T03:15:00Z", "entryPerArea": { - "NL": 169.7 + "NL": 75.01 } }, { - "deliveryStart": "2024-11-05T20:00:00Z", - "deliveryEnd": "2024-11-05T21:00:00Z", + "deliveryStart": "2025-10-01T03:15:00Z", + "deliveryEnd": "2025-10-01T03:30:00Z", "entryPerArea": { - "NL": 129.9 + "NL": 80.88 } }, { - "deliveryStart": "2024-11-05T21:00:00Z", - "deliveryEnd": "2024-11-05T22:00:00Z", + "deliveryStart": "2025-10-01T03:30:00Z", + "deliveryEnd": "2025-10-01T03:45:00Z", "entryPerArea": { - "NL": 117.77 + "NL": 88.18 } }, { - "deliveryStart": "2024-11-05T22:00:00Z", - "deliveryEnd": "2024-11-05T23:00:00Z", + "deliveryStart": "2025-10-01T03:45:00Z", + "deliveryEnd": "2025-10-01T04:00:00Z", "entryPerArea": { - "NL": 110.03 + "NL": 97.34 + } + }, + { + "deliveryStart": "2025-10-01T04:00:00Z", + "deliveryEnd": "2025-10-01T04:15:00Z", + "entryPerArea": { + "NL": 87.65 + } + }, + { + "deliveryStart": "2025-10-01T04:15:00Z", + "deliveryEnd": "2025-10-01T04:30:00Z", + "entryPerArea": { + "NL": 107.93 + } + }, + { + "deliveryStart": "2025-10-01T04:30:00Z", + "deliveryEnd": "2025-10-01T04:45:00Z", + "entryPerArea": { + "NL": 123.95 + } + }, + { + "deliveryStart": "2025-10-01T04:45:00Z", + "deliveryEnd": "2025-10-01T05:00:00Z", + "entryPerArea": { + "NL": 143.66 + } + }, + { + "deliveryStart": "2025-10-01T05:00:00Z", + "deliveryEnd": "2025-10-01T05:15:00Z", + "entryPerArea": { + "NL": 150.66 + } + }, + { + "deliveryStart": "2025-10-01T05:15:00Z", + "deliveryEnd": "2025-10-01T05:30:00Z", + "entryPerArea": { + "NL": 171.48 + } + }, + { + "deliveryStart": "2025-10-01T05:30:00Z", + "deliveryEnd": "2025-10-01T05:45:00Z", + "entryPerArea": { + "NL": 172.01 + } + }, + { + "deliveryStart": "2025-10-01T05:45:00Z", + "deliveryEnd": "2025-10-01T06:00:00Z", + "entryPerArea": { + "NL": 163.35 + } + }, + { + "deliveryStart": "2025-10-01T06:00:00Z", + "deliveryEnd": "2025-10-01T06:15:00Z", + "entryPerArea": { + "NL": 198.33 + } + }, + { + "deliveryStart": "2025-10-01T06:15:00Z", + "deliveryEnd": "2025-10-01T06:30:00Z", + "entryPerArea": { + "NL": 142.86 + } + }, + { + "deliveryStart": "2025-10-01T06:30:00Z", + "deliveryEnd": "2025-10-01T06:45:00Z", + "entryPerArea": { + "NL": 117.23 + } + }, + { + "deliveryStart": "2025-10-01T06:45:00Z", + "deliveryEnd": "2025-10-01T07:00:00Z", + "entryPerArea": { + "NL": 95.25 + } + }, + { + "deliveryStart": "2025-10-01T07:00:00Z", + "deliveryEnd": "2025-10-01T07:15:00Z", + "entryPerArea": { + "NL": 139.01 + } + }, + { + "deliveryStart": "2025-10-01T07:15:00Z", + "deliveryEnd": "2025-10-01T07:30:00Z", + "entryPerArea": { + "NL": 105.01 + } + }, + { + "deliveryStart": "2025-10-01T07:30:00Z", + "deliveryEnd": "2025-10-01T07:45:00Z", + "entryPerArea": { + "NL": 93.48 + } + }, + { + "deliveryStart": "2025-10-01T07:45:00Z", + "deliveryEnd": "2025-10-01T08:00:00Z", + "entryPerArea": { + "NL": 79.96 + } + }, + { + "deliveryStart": "2025-10-01T08:00:00Z", + "deliveryEnd": "2025-10-01T08:15:00Z", + "entryPerArea": { + "NL": 102.82 + } + }, + { + "deliveryStart": "2025-10-01T08:15:00Z", + "deliveryEnd": "2025-10-01T08:30:00Z", + "entryPerArea": { + "NL": 89.23 + } + }, + { + "deliveryStart": "2025-10-01T08:30:00Z", + "deliveryEnd": "2025-10-01T08:45:00Z", + "entryPerArea": { + "NL": 78.16 + } + }, + { + "deliveryStart": "2025-10-01T08:45:00Z", + "deliveryEnd": "2025-10-01T09:00:00Z", + "entryPerArea": { + "NL": 63.7 + } + }, + { + "deliveryStart": "2025-10-01T09:00:00Z", + "deliveryEnd": "2025-10-01T09:15:00Z", + "entryPerArea": { + "NL": 79.97 + } + }, + { + "deliveryStart": "2025-10-01T09:15:00Z", + "deliveryEnd": "2025-10-01T09:30:00Z", + "entryPerArea": { + "NL": 68.06 + } + }, + { + "deliveryStart": "2025-10-01T09:30:00Z", + "deliveryEnd": "2025-10-01T09:45:00Z", + "entryPerArea": { + "NL": 61.13 + } + }, + { + "deliveryStart": "2025-10-01T09:45:00Z", + "deliveryEnd": "2025-10-01T10:00:00Z", + "entryPerArea": { + "NL": 56.19 + } + }, + { + "deliveryStart": "2025-10-01T10:00:00Z", + "deliveryEnd": "2025-10-01T10:15:00Z", + "entryPerArea": { + "NL": 61.69 + } + }, + { + "deliveryStart": "2025-10-01T10:15:00Z", + "deliveryEnd": "2025-10-01T10:30:00Z", + "entryPerArea": { + "NL": 57.42 + } + }, + { + "deliveryStart": "2025-10-01T10:30:00Z", + "deliveryEnd": "2025-10-01T10:45:00Z", + "entryPerArea": { + "NL": 57.86 + } + }, + { + "deliveryStart": "2025-10-01T10:45:00Z", + "deliveryEnd": "2025-10-01T11:00:00Z", + "entryPerArea": { + "NL": 57.42 + } + }, + { + "deliveryStart": "2025-10-01T11:00:00Z", + "deliveryEnd": "2025-10-01T11:15:00Z", + "entryPerArea": { + "NL": 57.09 + } + }, + { + "deliveryStart": "2025-10-01T11:15:00Z", + "deliveryEnd": "2025-10-01T11:30:00Z", + "entryPerArea": { + "NL": 58.78 + } + }, + { + "deliveryStart": "2025-10-01T11:30:00Z", + "deliveryEnd": "2025-10-01T11:45:00Z", + "entryPerArea": { + "NL": 60.07 + } + }, + { + "deliveryStart": "2025-10-01T11:45:00Z", + "deliveryEnd": "2025-10-01T12:00:00Z", + "entryPerArea": { + "NL": 61.14 + } + }, + { + "deliveryStart": "2025-10-01T12:00:00Z", + "deliveryEnd": "2025-10-01T12:15:00Z", + "entryPerArea": { + "NL": 54.35 + } + }, + { + "deliveryStart": "2025-10-01T12:15:00Z", + "deliveryEnd": "2025-10-01T12:30:00Z", + "entryPerArea": { + "NL": 60.62 + } + }, + { + "deliveryStart": "2025-10-01T12:30:00Z", + "deliveryEnd": "2025-10-01T12:45:00Z", + "entryPerArea": { + "NL": 64.4 + } + }, + { + "deliveryStart": "2025-10-01T12:45:00Z", + "deliveryEnd": "2025-10-01T13:00:00Z", + "entryPerArea": { + "NL": 71.9 + } + }, + { + "deliveryStart": "2025-10-01T13:00:00Z", + "deliveryEnd": "2025-10-01T13:15:00Z", + "entryPerArea": { + "NL": 57.55 + } + }, + { + "deliveryStart": "2025-10-01T13:15:00Z", + "deliveryEnd": "2025-10-01T13:30:00Z", + "entryPerArea": { + "NL": 66.28 + } + }, + { + "deliveryStart": "2025-10-01T13:30:00Z", + "deliveryEnd": "2025-10-01T13:45:00Z", + "entryPerArea": { + "NL": 77.91 + } + }, + { + "deliveryStart": "2025-10-01T13:45:00Z", + "deliveryEnd": "2025-10-01T14:00:00Z", + "entryPerArea": { + "NL": 88.62 + } + }, + { + "deliveryStart": "2025-10-01T14:00:00Z", + "deliveryEnd": "2025-10-01T14:15:00Z", + "entryPerArea": { + "NL": 55.07 + } + }, + { + "deliveryStart": "2025-10-01T14:15:00Z", + "deliveryEnd": "2025-10-01T14:30:00Z", + "entryPerArea": { + "NL": 80.77 + } + }, + { + "deliveryStart": "2025-10-01T14:30:00Z", + "deliveryEnd": "2025-10-01T14:45:00Z", + "entryPerArea": { + "NL": 95.16 + } + }, + { + "deliveryStart": "2025-10-01T14:45:00Z", + "deliveryEnd": "2025-10-01T15:00:00Z", + "entryPerArea": { + "NL": 109.0 + } + }, + { + "deliveryStart": "2025-10-01T15:00:00Z", + "deliveryEnd": "2025-10-01T15:15:00Z", + "entryPerArea": { + "NL": 76.45 + } + }, + { + "deliveryStart": "2025-10-01T15:15:00Z", + "deliveryEnd": "2025-10-01T15:30:00Z", + "entryPerArea": { + "NL": 106.42 + } + }, + { + "deliveryStart": "2025-10-01T15:30:00Z", + "deliveryEnd": "2025-10-01T15:45:00Z", + "entryPerArea": { + "NL": 139.35 + } + }, + { + "deliveryStart": "2025-10-01T15:45:00Z", + "deliveryEnd": "2025-10-01T16:00:00Z", + "entryPerArea": { + "NL": 190.18 + } + }, + { + "deliveryStart": "2025-10-01T16:00:00Z", + "deliveryEnd": "2025-10-01T16:15:00Z", + "entryPerArea": { + "NL": 141.68 + } + }, + { + "deliveryStart": "2025-10-01T16:15:00Z", + "deliveryEnd": "2025-10-01T16:30:00Z", + "entryPerArea": { + "NL": 192.84 + } + }, + { + "deliveryStart": "2025-10-01T16:30:00Z", + "deliveryEnd": "2025-10-01T16:45:00Z", + "entryPerArea": { + "NL": 285.0 + } + }, + { + "deliveryStart": "2025-10-01T16:45:00Z", + "deliveryEnd": "2025-10-01T17:00:00Z", + "entryPerArea": { + "NL": 381.0 + } + }, + { + "deliveryStart": "2025-10-01T17:00:00Z", + "deliveryEnd": "2025-10-01T17:15:00Z", + "entryPerArea": { + "NL": 408.5 + } + }, + { + "deliveryStart": "2025-10-01T17:15:00Z", + "deliveryEnd": "2025-10-01T17:30:00Z", + "entryPerArea": { + "NL": 376.39 + } + }, + { + "deliveryStart": "2025-10-01T17:30:00Z", + "deliveryEnd": "2025-10-01T17:45:00Z", + "entryPerArea": { + "NL": 321.94 + } + }, + { + "deliveryStart": "2025-10-01T17:45:00Z", + "deliveryEnd": "2025-10-01T18:00:00Z", + "entryPerArea": { + "NL": 253.14 + } + }, + { + "deliveryStart": "2025-10-01T18:00:00Z", + "deliveryEnd": "2025-10-01T18:15:00Z", + "entryPerArea": { + "NL": 217.5 + } + }, + { + "deliveryStart": "2025-10-01T18:15:00Z", + "deliveryEnd": "2025-10-01T18:30:00Z", + "entryPerArea": { + "NL": 154.56 + } + }, + { + "deliveryStart": "2025-10-01T18:30:00Z", + "deliveryEnd": "2025-10-01T18:45:00Z", + "entryPerArea": { + "NL": 123.11 + } + }, + { + "deliveryStart": "2025-10-01T18:45:00Z", + "deliveryEnd": "2025-10-01T19:00:00Z", + "entryPerArea": { + "NL": 104.83 + } + }, + { + "deliveryStart": "2025-10-01T19:00:00Z", + "deliveryEnd": "2025-10-01T19:15:00Z", + "entryPerArea": { + "NL": 125.76 + } + }, + { + "deliveryStart": "2025-10-01T19:15:00Z", + "deliveryEnd": "2025-10-01T19:30:00Z", + "entryPerArea": { + "NL": 115.82 + } + }, + { + "deliveryStart": "2025-10-01T19:30:00Z", + "deliveryEnd": "2025-10-01T19:45:00Z", + "entryPerArea": { + "NL": 97.54 + } + }, + { + "deliveryStart": "2025-10-01T19:45:00Z", + "deliveryEnd": "2025-10-01T20:00:00Z", + "entryPerArea": { + "NL": 87.96 + } + }, + { + "deliveryStart": "2025-10-01T20:00:00Z", + "deliveryEnd": "2025-10-01T20:15:00Z", + "entryPerArea": { + "NL": 106.69 + } + }, + { + "deliveryStart": "2025-10-01T20:15:00Z", + "deliveryEnd": "2025-10-01T20:30:00Z", + "entryPerArea": { + "NL": 98.76 + } + }, + { + "deliveryStart": "2025-10-01T20:30:00Z", + "deliveryEnd": "2025-10-01T20:45:00Z", + "entryPerArea": { + "NL": 95.32 + } + }, + { + "deliveryStart": "2025-10-01T20:45:00Z", + "deliveryEnd": "2025-10-01T21:00:00Z", + "entryPerArea": { + "NL": 88.02 + } + }, + { + "deliveryStart": "2025-10-01T21:00:00Z", + "deliveryEnd": "2025-10-01T21:15:00Z", + "entryPerArea": { + "NL": 93.53 + } + }, + { + "deliveryStart": "2025-10-01T21:15:00Z", + "deliveryEnd": "2025-10-01T21:30:00Z", + "entryPerArea": { + "NL": 88.75 + } + }, + { + "deliveryStart": "2025-10-01T21:30:00Z", + "deliveryEnd": "2025-10-01T21:45:00Z", + "entryPerArea": { + "NL": 90.62 + } + }, + { + "deliveryStart": "2025-10-01T21:45:00Z", + "deliveryEnd": "2025-10-01T22:00:00Z", + "entryPerArea": { + "NL": 82.6 } } ], "blockPriceAggregates": [ { "blockName": "Off-peak 1", - "deliveryStart": "2024-11-04T23:00:00Z", - "deliveryEnd": "2024-11-05T07:00:00Z", + "deliveryStart": "2025-09-30T22:00:00Z", + "deliveryEnd": "2025-10-01T06:00:00Z", "averagePricePerArea": { "NL": { - "average": 98.96, - "min": 83.63, - "max": 135.99 + "average": 97.54, + "min": 75.01, + "max": 172.01 } } }, { "blockName": "Peak", - "deliveryStart": "2024-11-05T07:00:00Z", - "deliveryEnd": "2024-11-05T19:00:00Z", + "deliveryStart": "2025-10-01T06:00:00Z", + "deliveryEnd": "2025-10-01T18:00:00Z", "averagePricePerArea": { "NL": { - "average": 202.93, - "min": 94.92, - "max": 472.99 + "average": 120.76, + "min": 54.35, + "max": 408.5 } } }, { "blockName": "Off-peak 2", - "deliveryStart": "2024-11-05T19:00:00Z", - "deliveryEnd": "2024-11-05T23:00:00Z", + "deliveryStart": "2025-10-01T18:00:00Z", + "deliveryEnd": "2025-10-01T22:00:00Z", "averagePricePerArea": { "NL": { - "average": 131.85, - "min": 110.03, - "max": 169.7 + "average": 110.71, + "min": 82.6, + "max": 217.5 } } } @@ -223,7 +727,7 @@ "areaAverages": [ { "areaCode": "NL", - "price": 156.43 + "price": 111.34 } ] } diff --git a/tests/components/nordpool/fixtures/delivery_period_today.json b/tests/components/nordpool/fixtures/delivery_period_today.json index df48c32a9a9..ecd7b386802 100644 --- a/tests/components/nordpool/fixtures/delivery_period_today.json +++ b/tests/components/nordpool/fixtures/delivery_period_today.json @@ -1,258 +1,834 @@ { - "deliveryDateCET": "2024-11-05", + "deliveryDateCET": "2025-10-01", "version": 3, - "updatedAt": "2024-11-04T12:15:03.9456464Z", + "updatedAt": "2025-09-30T12:08:16.4448023Z", "deliveryAreas": ["SE3", "SE4"], "market": "DayAhead", "multiAreaEntries": [ { - "deliveryStart": "2024-11-04T23:00:00Z", - "deliveryEnd": "2024-11-05T00:00:00Z", + "deliveryStart": "2025-09-30T22:00:00Z", + "deliveryEnd": "2025-09-30T22:15:00Z", "entryPerArea": { - "SE3": 250.73, - "SE4": 283.79 + "SE3": 556.68, + "SE4": 642.22 } }, { - "deliveryStart": "2024-11-05T00:00:00Z", - "deliveryEnd": "2024-11-05T01:00:00Z", + "deliveryStart": "2025-09-30T22:15:00Z", + "deliveryEnd": "2025-09-30T22:30:00Z", "entryPerArea": { - "SE3": 76.36, - "SE4": 81.36 + "SE3": 519.88, + "SE4": 600.12 } }, { - "deliveryStart": "2024-11-05T01:00:00Z", - "deliveryEnd": "2024-11-05T02:00:00Z", + "deliveryStart": "2025-09-30T22:30:00Z", + "deliveryEnd": "2025-09-30T22:45:00Z", "entryPerArea": { - "SE3": 73.92, - "SE4": 79.15 + "SE3": 508.28, + "SE4": 586.3 } }, { - "deliveryStart": "2024-11-05T02:00:00Z", - "deliveryEnd": "2024-11-05T03:00:00Z", + "deliveryStart": "2025-09-30T22:45:00Z", + "deliveryEnd": "2025-09-30T23:00:00Z", "entryPerArea": { - "SE3": 61.69, - "SE4": 65.19 + "SE3": 509.93, + "SE4": 589.62 } }, { - "deliveryStart": "2024-11-05T03:00:00Z", - "deliveryEnd": "2024-11-05T04:00:00Z", + "deliveryStart": "2025-09-30T23:00:00Z", + "deliveryEnd": "2025-09-30T23:15:00Z", "entryPerArea": { - "SE3": 64.6, - "SE4": 68.44 + "SE3": 501.64, + "SE4": 577.24 } }, { - "deliveryStart": "2024-11-05T04:00:00Z", - "deliveryEnd": "2024-11-05T05:00:00Z", + "deliveryStart": "2025-09-30T23:15:00Z", + "deliveryEnd": "2025-09-30T23:30:00Z", "entryPerArea": { - "SE3": 453.27, - "SE4": 516.71 + "SE3": 509.05, + "SE4": 585.42 } }, { - "deliveryStart": "2024-11-05T05:00:00Z", - "deliveryEnd": "2024-11-05T06:00:00Z", + "deliveryStart": "2025-09-30T23:30:00Z", + "deliveryEnd": "2025-09-30T23:45:00Z", "entryPerArea": { - "SE3": 996.28, - "SE4": 1240.85 + "SE3": 491.03, + "SE4": 567.18 } }, { - "deliveryStart": "2024-11-05T06:00:00Z", - "deliveryEnd": "2024-11-05T07:00:00Z", + "deliveryStart": "2025-09-30T23:45:00Z", + "deliveryEnd": "2025-10-01T00:00:00Z", "entryPerArea": { - "SE3": 1406.14, - "SE4": 1648.25 + "SE3": 442.07, + "SE4": 517.45 } }, { - "deliveryStart": "2024-11-05T07:00:00Z", - "deliveryEnd": "2024-11-05T08:00:00Z", + "deliveryStart": "2025-10-01T00:00:00Z", + "deliveryEnd": "2025-10-01T00:15:00Z", "entryPerArea": { - "SE3": 1346.54, - "SE4": 1570.5 + "SE3": 504.08, + "SE4": 580.55 } }, { - "deliveryStart": "2024-11-05T08:00:00Z", - "deliveryEnd": "2024-11-05T09:00:00Z", + "deliveryStart": "2025-10-01T00:15:00Z", + "deliveryEnd": "2025-10-01T00:30:00Z", "entryPerArea": { - "SE3": 1150.28, - "SE4": 1345.37 + "SE3": 504.85, + "SE4": 581.55 } }, { - "deliveryStart": "2024-11-05T09:00:00Z", - "deliveryEnd": "2024-11-05T10:00:00Z", + "deliveryStart": "2025-10-01T00:30:00Z", + "deliveryEnd": "2025-10-01T00:45:00Z", "entryPerArea": { - "SE3": 1031.32, - "SE4": 1206.51 + "SE3": 504.3, + "SE4": 580.78 } }, { - "deliveryStart": "2024-11-05T10:00:00Z", - "deliveryEnd": "2024-11-05T11:00:00Z", + "deliveryStart": "2025-10-01T00:45:00Z", + "deliveryEnd": "2025-10-01T01:00:00Z", "entryPerArea": { - "SE3": 927.37, - "SE4": 1085.8 + "SE3": 506.29, + "SE4": 583.1 } }, { - "deliveryStart": "2024-11-05T11:00:00Z", - "deliveryEnd": "2024-11-05T12:00:00Z", + "deliveryStart": "2025-10-01T01:00:00Z", + "deliveryEnd": "2025-10-01T01:15:00Z", "entryPerArea": { - "SE3": 925.05, - "SE4": 1081.72 + "SE3": 442.07, + "SE4": 515.46 } }, { - "deliveryStart": "2024-11-05T12:00:00Z", - "deliveryEnd": "2024-11-05T13:00:00Z", + "deliveryStart": "2025-10-01T01:15:00Z", + "deliveryEnd": "2025-10-01T01:30:00Z", "entryPerArea": { - "SE3": 949.49, - "SE4": 1130.38 + "SE3": 441.96, + "SE4": 517.23 } }, { - "deliveryStart": "2024-11-05T13:00:00Z", - "deliveryEnd": "2024-11-05T14:00:00Z", + "deliveryStart": "2025-10-01T01:30:00Z", + "deliveryEnd": "2025-10-01T01:45:00Z", "entryPerArea": { - "SE3": 1042.03, - "SE4": 1256.91 + "SE3": 442.07, + "SE4": 516.23 } }, { - "deliveryStart": "2024-11-05T14:00:00Z", - "deliveryEnd": "2024-11-05T15:00:00Z", + "deliveryStart": "2025-10-01T01:45:00Z", + "deliveryEnd": "2025-10-01T02:00:00Z", "entryPerArea": { - "SE3": 1258.89, - "SE4": 1765.82 + "SE3": 442.07, + "SE4": 516.23 } }, { - "deliveryStart": "2024-11-05T15:00:00Z", - "deliveryEnd": "2024-11-05T16:00:00Z", + "deliveryStart": "2025-10-01T02:00:00Z", + "deliveryEnd": "2025-10-01T02:15:00Z", "entryPerArea": { - "SE3": 1816.45, - "SE4": 2522.55 + "SE3": 441.96, + "SE4": 517.34 } }, { - "deliveryStart": "2024-11-05T16:00:00Z", - "deliveryEnd": "2024-11-05T17:00:00Z", + "deliveryStart": "2025-10-01T02:15:00Z", + "deliveryEnd": "2025-10-01T02:30:00Z", "entryPerArea": { - "SE3": 2512.65, - "SE4": 3533.03 + "SE3": 483.3, + "SE4": 559.11 } }, { - "deliveryStart": "2024-11-05T17:00:00Z", - "deliveryEnd": "2024-11-05T18:00:00Z", + "deliveryStart": "2025-10-01T02:30:00Z", + "deliveryEnd": "2025-10-01T02:45:00Z", "entryPerArea": { - "SE3": 1819.83, - "SE4": 2524.06 + "SE3": 484.29, + "SE4": 559.0 } }, { - "deliveryStart": "2024-11-05T18:00:00Z", - "deliveryEnd": "2024-11-05T19:00:00Z", + "deliveryStart": "2025-10-01T02:45:00Z", + "deliveryEnd": "2025-10-01T03:00:00Z", "entryPerArea": { - "SE3": 1011.77, + "SE3": 574.7, + "SE4": 659.35 + } + }, + { + "deliveryStart": "2025-10-01T03:00:00Z", + "deliveryEnd": "2025-10-01T03:15:00Z", + "entryPerArea": { + "SE3": 543.31, + "SE4": 631.95 + } + }, + { + "deliveryStart": "2025-10-01T03:15:00Z", + "deliveryEnd": "2025-10-01T03:30:00Z", + "entryPerArea": { + "SE3": 578.01, + "SE4": 671.18 + } + }, + { + "deliveryStart": "2025-10-01T03:30:00Z", + "deliveryEnd": "2025-10-01T03:45:00Z", + "entryPerArea": { + "SE3": 774.96, + "SE4": 893.1 + } + }, + { + "deliveryStart": "2025-10-01T03:45:00Z", + "deliveryEnd": "2025-10-01T04:00:00Z", + "entryPerArea": { + "SE3": 787.0, + "SE4": 909.79 + } + }, + { + "deliveryStart": "2025-10-01T04:00:00Z", + "deliveryEnd": "2025-10-01T04:15:00Z", + "entryPerArea": { + "SE3": 902.38, + "SE4": 1041.86 + } + }, + { + "deliveryStart": "2025-10-01T04:15:00Z", + "deliveryEnd": "2025-10-01T04:30:00Z", + "entryPerArea": { + "SE3": 1079.32, + "SE4": 1254.17 + } + }, + { + "deliveryStart": "2025-10-01T04:30:00Z", + "deliveryEnd": "2025-10-01T04:45:00Z", + "entryPerArea": { + "SE3": 1222.67, + "SE4": 1421.93 + } + }, + { + "deliveryStart": "2025-10-01T04:45:00Z", + "deliveryEnd": "2025-10-01T05:00:00Z", + "entryPerArea": { + "SE3": 1394.63, + "SE4": 1623.08 + } + }, + { + "deliveryStart": "2025-10-01T05:00:00Z", + "deliveryEnd": "2025-10-01T05:15:00Z", + "entryPerArea": { + "SE3": 1529.36, + "SE4": 1787.86 + } + }, + { + "deliveryStart": "2025-10-01T05:15:00Z", + "deliveryEnd": "2025-10-01T05:30:00Z", + "entryPerArea": { + "SE3": 1724.53, + "SE4": 2015.75 + } + }, + { + "deliveryStart": "2025-10-01T05:30:00Z", + "deliveryEnd": "2025-10-01T05:45:00Z", + "entryPerArea": { + "SE3": 1809.96, + "SE4": 2029.34 + } + }, + { + "deliveryStart": "2025-10-01T05:45:00Z", + "deliveryEnd": "2025-10-01T06:00:00Z", + "entryPerArea": { + "SE3": 1713.04, + "SE4": 1920.15 + } + }, + { + "deliveryStart": "2025-10-01T06:00:00Z", + "deliveryEnd": "2025-10-01T06:15:00Z", + "entryPerArea": { + "SE3": 1925.9, + "SE4": 2162.63 + } + }, + { + "deliveryStart": "2025-10-01T06:15:00Z", + "deliveryEnd": "2025-10-01T06:30:00Z", + "entryPerArea": { + "SE3": 1440.06, + "SE4": 1614.01 + } + }, + { + "deliveryStart": "2025-10-01T06:30:00Z", + "deliveryEnd": "2025-10-01T06:45:00Z", + "entryPerArea": { + "SE3": 1183.32, + "SE4": 1319.37 + } + }, + { + "deliveryStart": "2025-10-01T06:45:00Z", + "deliveryEnd": "2025-10-01T07:00:00Z", + "entryPerArea": { + "SE3": 962.95, + "SE4": 1068.71 + } + }, + { + "deliveryStart": "2025-10-01T07:00:00Z", + "deliveryEnd": "2025-10-01T07:15:00Z", + "entryPerArea": { + "SE3": 1402.04, + "SE4": 1569.92 + } + }, + { + "deliveryStart": "2025-10-01T07:15:00Z", + "deliveryEnd": "2025-10-01T07:30:00Z", + "entryPerArea": { + "SE3": 1060.65, + "SE4": 1178.46 + } + }, + { + "deliveryStart": "2025-10-01T07:30:00Z", + "deliveryEnd": "2025-10-01T07:45:00Z", + "entryPerArea": { + "SE3": 949.13, + "SE4": 1050.59 + } + }, + { + "deliveryStart": "2025-10-01T07:45:00Z", + "deliveryEnd": "2025-10-01T08:00:00Z", + "entryPerArea": { + "SE3": 841.82, + "SE4": 938.3 + } + }, + { + "deliveryStart": "2025-10-01T08:00:00Z", + "deliveryEnd": "2025-10-01T08:15:00Z", + "entryPerArea": { + "SE3": 1037.44, + "SE4": 1141.44 + } + }, + { + "deliveryStart": "2025-10-01T08:15:00Z", + "deliveryEnd": "2025-10-01T08:30:00Z", + "entryPerArea": { + "SE3": 950.13, + "SE4": 1041.64 + } + }, + { + "deliveryStart": "2025-10-01T08:30:00Z", + "deliveryEnd": "2025-10-01T08:45:00Z", + "entryPerArea": { + "SE3": 826.13, + "SE4": 905.04 + } + }, + { + "deliveryStart": "2025-10-01T08:45:00Z", + "deliveryEnd": "2025-10-01T09:00:00Z", + "entryPerArea": { + "SE3": 684.55, + "SE4": 754.62 + } + }, + { + "deliveryStart": "2025-10-01T09:00:00Z", + "deliveryEnd": "2025-10-01T09:15:00Z", + "entryPerArea": { + "SE3": 861.6, + "SE4": 936.09 + } + }, + { + "deliveryStart": "2025-10-01T09:15:00Z", + "deliveryEnd": "2025-10-01T09:30:00Z", + "entryPerArea": { + "SE3": 722.79, + "SE4": 799.6 + } + }, + { + "deliveryStart": "2025-10-01T09:30:00Z", + "deliveryEnd": "2025-10-01T09:45:00Z", + "entryPerArea": { + "SE3": 640.57, + "SE4": 718.59 + } + }, + { + "deliveryStart": "2025-10-01T09:45:00Z", + "deliveryEnd": "2025-10-01T10:00:00Z", + "entryPerArea": { + "SE3": 607.74, + "SE4": 683.12 + } + }, + { + "deliveryStart": "2025-10-01T10:00:00Z", + "deliveryEnd": "2025-10-01T10:15:00Z", + "entryPerArea": { + "SE3": 674.05, + "SE4": 752.41 + } + }, + { + "deliveryStart": "2025-10-01T10:15:00Z", + "deliveryEnd": "2025-10-01T10:30:00Z", + "entryPerArea": { + "SE3": 638.58, + "SE4": 717.49 + } + }, + { + "deliveryStart": "2025-10-01T10:30:00Z", + "deliveryEnd": "2025-10-01T10:45:00Z", + "entryPerArea": { + "SE3": 638.47, + "SE4": 719.81 + } + }, + { + "deliveryStart": "2025-10-01T10:45:00Z", + "deliveryEnd": "2025-10-01T11:00:00Z", + "entryPerArea": { + "SE3": 634.82, + "SE4": 717.16 + } + }, + { + "deliveryStart": "2025-10-01T11:00:00Z", + "deliveryEnd": "2025-10-01T11:15:00Z", + "entryPerArea": { + "SE3": 637.36, + "SE4": 721.58 + } + }, + { + "deliveryStart": "2025-10-01T11:15:00Z", + "deliveryEnd": "2025-10-01T11:30:00Z", + "entryPerArea": { + "SE3": 660.68, + "SE4": 746.33 + } + }, + { + "deliveryStart": "2025-10-01T11:30:00Z", + "deliveryEnd": "2025-10-01T11:45:00Z", + "entryPerArea": { + "SE3": 679.14, + "SE4": 766.45 + } + }, + { + "deliveryStart": "2025-10-01T11:45:00Z", + "deliveryEnd": "2025-10-01T12:00:00Z", + "entryPerArea": { + "SE3": 694.61, + "SE4": 782.91 + } + }, + { + "deliveryStart": "2025-10-01T12:00:00Z", + "deliveryEnd": "2025-10-01T12:15:00Z", + "entryPerArea": { + "SE3": 622.33, + "SE4": 708.87 + } + }, + { + "deliveryStart": "2025-10-01T12:15:00Z", + "deliveryEnd": "2025-10-01T12:30:00Z", + "entryPerArea": { + "SE3": 685.44, + "SE4": 775.84 + } + }, + { + "deliveryStart": "2025-10-01T12:30:00Z", + "deliveryEnd": "2025-10-01T12:45:00Z", + "entryPerArea": { + "SE3": 732.85, + "SE4": 826.57 + } + }, + { + "deliveryStart": "2025-10-01T12:45:00Z", + "deliveryEnd": "2025-10-01T13:00:00Z", + "entryPerArea": { + "SE3": 801.92, + "SE4": 901.28 + } + }, + { + "deliveryStart": "2025-10-01T13:00:00Z", + "deliveryEnd": "2025-10-01T13:15:00Z", + "entryPerArea": { + "SE3": 629.4, + "SE4": 717.93 + } + }, + { + "deliveryStart": "2025-10-01T13:15:00Z", + "deliveryEnd": "2025-10-01T13:30:00Z", + "entryPerArea": { + "SE3": 729.53, + "SE4": 825.46 + } + }, + { + "deliveryStart": "2025-10-01T13:30:00Z", + "deliveryEnd": "2025-10-01T13:45:00Z", + "entryPerArea": { + "SE3": 884.81, + "SE4": 983.95 + } + }, + { + "deliveryStart": "2025-10-01T13:45:00Z", + "deliveryEnd": "2025-10-01T14:00:00Z", + "entryPerArea": { + "SE3": 984.94, + "SE4": 1089.71 + } + }, + { + "deliveryStart": "2025-10-01T14:00:00Z", + "deliveryEnd": "2025-10-01T14:15:00Z", + "entryPerArea": { + "SE3": 615.26, + "SE4": 703.12 + } + }, + { + "deliveryStart": "2025-10-01T14:15:00Z", + "deliveryEnd": "2025-10-01T14:30:00Z", + "entryPerArea": { + "SE3": 902.94, + "SE4": 1002.74 + } + }, + { + "deliveryStart": "2025-10-01T14:30:00Z", + "deliveryEnd": "2025-10-01T14:45:00Z", + "entryPerArea": { + "SE3": 1043.85, + "SE4": 1158.35 + } + }, + { + "deliveryStart": "2025-10-01T14:45:00Z", + "deliveryEnd": "2025-10-01T15:00:00Z", + "entryPerArea": { + "SE3": 1075.12, + "SE4": 1194.15 + } + }, + { + "deliveryStart": "2025-10-01T15:00:00Z", + "deliveryEnd": "2025-10-01T15:15:00Z", + "entryPerArea": { + "SE3": 980.52, + "SE4": 1089.38 + } + }, + { + "deliveryStart": "2025-10-01T15:15:00Z", + "deliveryEnd": "2025-10-01T15:30:00Z", + "entryPerArea": { + "SE3": 1162.66, + "SE4": 1300.14 + } + }, + { + "deliveryStart": "2025-10-01T15:30:00Z", + "deliveryEnd": "2025-10-01T15:45:00Z", + "entryPerArea": { + "SE3": 1453.87, + "SE4": 1628.6 + } + }, + { + "deliveryStart": "2025-10-01T15:45:00Z", + "deliveryEnd": "2025-10-01T16:00:00Z", + "entryPerArea": { + "SE3": 1955.96, + "SE4": 2193.35 + } + }, + { + "deliveryStart": "2025-10-01T16:00:00Z", + "deliveryEnd": "2025-10-01T16:15:00Z", + "entryPerArea": { + "SE3": 1423.48, + "SE4": 1623.74 + } + }, + { + "deliveryStart": "2025-10-01T16:15:00Z", + "deliveryEnd": "2025-10-01T16:30:00Z", + "entryPerArea": { + "SE3": 1900.04, + "SE4": 2199.98 + } + }, + { + "deliveryStart": "2025-10-01T16:30:00Z", + "deliveryEnd": "2025-10-01T16:45:00Z", + "entryPerArea": { + "SE3": 2611.11, + "SE4": 3031.08 + } + }, + { + "deliveryStart": "2025-10-01T16:45:00Z", + "deliveryEnd": "2025-10-01T17:00:00Z", + "entryPerArea": { + "SE3": 3467.41, + "SE4": 4029.51 + } + }, + { + "deliveryStart": "2025-10-01T17:00:00Z", + "deliveryEnd": "2025-10-01T17:15:00Z", + "entryPerArea": { + "SE3": 3828.03, + "SE4": 4442.74 + } + }, + { + "deliveryStart": "2025-10-01T17:15:00Z", + "deliveryEnd": "2025-10-01T17:30:00Z", + "entryPerArea": { + "SE3": 3429.83, + "SE4": 3982.21 + } + }, + { + "deliveryStart": "2025-10-01T17:30:00Z", + "deliveryEnd": "2025-10-01T17:45:00Z", + "entryPerArea": { + "SE3": 2934.38, + "SE4": 3405.74 + } + }, + { + "deliveryStart": "2025-10-01T17:45:00Z", + "deliveryEnd": "2025-10-01T18:00:00Z", + "entryPerArea": { + "SE3": 2308.07, + "SE4": 2677.64 + } + }, + { + "deliveryStart": "2025-10-01T18:00:00Z", + "deliveryEnd": "2025-10-01T18:15:00Z", + "entryPerArea": { + "SE3": 1997.96, "SE4": 0.0 } }, { - "deliveryStart": "2024-11-05T19:00:00Z", - "deliveryEnd": "2024-11-05T20:00:00Z", + "deliveryStart": "2025-10-01T18:15:00Z", + "deliveryEnd": "2025-10-01T18:30:00Z", "entryPerArea": { - "SE3": 835.53, - "SE4": 1112.57 + "SE3": 1424.03, + "SE4": 1646.17 } }, { - "deliveryStart": "2024-11-05T20:00:00Z", - "deliveryEnd": "2024-11-05T21:00:00Z", + "deliveryStart": "2025-10-01T18:30:00Z", + "deliveryEnd": "2025-10-01T18:45:00Z", "entryPerArea": { - "SE3": 796.19, - "SE4": 1051.69 + "SE3": 1216.81, + "SE4": 1388.11 } }, { - "deliveryStart": "2024-11-05T21:00:00Z", - "deliveryEnd": "2024-11-05T22:00:00Z", + "deliveryStart": "2025-10-01T18:45:00Z", + "deliveryEnd": "2025-10-01T19:00:00Z", "entryPerArea": { - "SE3": 522.3, - "SE4": 662.44 + "SE3": 1070.15, + "SE4": 1204.65 } }, { - "deliveryStart": "2024-11-05T22:00:00Z", - "deliveryEnd": "2024-11-05T23:00:00Z", + "deliveryStart": "2025-10-01T19:00:00Z", + "deliveryEnd": "2025-10-01T19:15:00Z", "entryPerArea": { - "SE3": 289.14, - "SE4": 349.21 + "SE3": 1218.14, + "SE4": 1405.02 + } + }, + { + "deliveryStart": "2025-10-01T19:15:00Z", + "deliveryEnd": "2025-10-01T19:30:00Z", + "entryPerArea": { + "SE3": 1135.8, + "SE4": 1309.42 + } + }, + { + "deliveryStart": "2025-10-01T19:30:00Z", + "deliveryEnd": "2025-10-01T19:45:00Z", + "entryPerArea": { + "SE3": 959.96, + "SE4": 1115.69 + } + }, + { + "deliveryStart": "2025-10-01T19:45:00Z", + "deliveryEnd": "2025-10-01T20:00:00Z", + "entryPerArea": { + "SE3": 913.66, + "SE4": 1064.52 + } + }, + { + "deliveryStart": "2025-10-01T20:00:00Z", + "deliveryEnd": "2025-10-01T20:15:00Z", + "entryPerArea": { + "SE3": 1001.63, + "SE4": 1161.22 + } + }, + { + "deliveryStart": "2025-10-01T20:15:00Z", + "deliveryEnd": "2025-10-01T20:30:00Z", + "entryPerArea": { + "SE3": 933.0, + "SE4": 1083.08 + } + }, + { + "deliveryStart": "2025-10-01T20:30:00Z", + "deliveryEnd": "2025-10-01T20:45:00Z", + "entryPerArea": { + "SE3": 874.53, + "SE4": 1017.66 + } + }, + { + "deliveryStart": "2025-10-01T20:45:00Z", + "deliveryEnd": "2025-10-01T21:00:00Z", + "entryPerArea": { + "SE3": 821.71, + "SE4": 955.32 + } + }, + { + "deliveryStart": "2025-10-01T21:00:00Z", + "deliveryEnd": "2025-10-01T21:15:00Z", + "entryPerArea": { + "SE3": 860.5, + "SE4": 997.32 + } + }, + { + "deliveryStart": "2025-10-01T21:15:00Z", + "deliveryEnd": "2025-10-01T21:30:00Z", + "entryPerArea": { + "SE3": 840.16, + "SE4": 977.87 + } + }, + { + "deliveryStart": "2025-10-01T21:30:00Z", + "deliveryEnd": "2025-10-01T21:45:00Z", + "entryPerArea": { + "SE3": 820.05, + "SE4": 954.66 + } + }, + { + "deliveryStart": "2025-10-01T21:45:00Z", + "deliveryEnd": "2025-10-01T22:00:00Z", + "entryPerArea": { + "SE3": 785.68, + "SE4": 912.22 } } ], "blockPriceAggregates": [ { "blockName": "Off-peak 1", - "deliveryStart": "2024-11-04T23:00:00Z", - "deliveryEnd": "2024-11-05T07:00:00Z", + "deliveryStart": "2025-09-30T22:00:00Z", + "deliveryEnd": "2025-10-01T06:00:00Z", "averagePricePerArea": { "SE3": { - "average": 422.87, - "min": 61.69, - "max": 1406.14 + "average": 745.93, + "min": 441.96, + "max": 1809.96 }, "SE4": { - "average": 497.97, - "min": 65.19, - "max": 1648.25 + "average": 860.99, + "min": 515.46, + "max": 2029.34 } } }, { "blockName": "Peak", - "deliveryStart": "2024-11-05T07:00:00Z", - "deliveryEnd": "2024-11-05T19:00:00Z", + "deliveryStart": "2025-10-01T06:00:00Z", + "deliveryEnd": "2025-10-01T18:00:00Z", "averagePricePerArea": { "SE3": { - "average": 1315.97, - "min": 925.05, - "max": 2512.65 + "average": 1219.13, + "min": 607.74, + "max": 3828.03 }, "SE4": { - "average": 1735.59, - "min": 1081.72, - "max": 3533.03 + "average": 1381.22, + "min": 683.12, + "max": 4442.74 } } }, { "blockName": "Off-peak 2", - "deliveryStart": "2024-11-05T19:00:00Z", - "deliveryEnd": "2024-11-05T23:00:00Z", + "deliveryStart": "2025-10-01T18:00:00Z", + "deliveryEnd": "2025-10-01T22:00:00Z", "averagePricePerArea": { "SE3": { - "average": 610.79, - "min": 289.14, - "max": 835.53 + "average": 1054.61, + "min": 785.68, + "max": 1997.96 }, "SE4": { - "average": 793.98, - "min": 349.21, - "max": 1112.57 + "average": 1219.07, + "min": 912.22, + "max": 2312.16 } } } ], "currency": "SEK", - "exchangeRate": 11.6402, + "exchangeRate": 11.05186, "areaStates": [ { "state": "Final", @@ -262,11 +838,11 @@ "areaAverages": [ { "areaCode": "SE3", - "price": 900.74 + "price": 1033.98 }, { "areaCode": "SE4", - "price": 1166.12 + "price": 1180.78 } ] } diff --git a/tests/components/nordpool/fixtures/delivery_period_tomorrow.json b/tests/components/nordpool/fixtures/delivery_period_tomorrow.json index abaa24e93ed..0e64088d33b 100644 --- a/tests/components/nordpool/fixtures/delivery_period_tomorrow.json +++ b/tests/components/nordpool/fixtures/delivery_period_tomorrow.json @@ -1,258 +1,834 @@ { - "deliveryDateCET": "2024-11-06", + "deliveryDateCET": "2025-10-02", "version": 3, - "updatedAt": "2024-11-05T12:12:51.9853434Z", + "updatedAt": "2025-10-01T11:25:06.1484362Z", "deliveryAreas": ["SE3", "SE4"], "market": "DayAhead", "multiAreaEntries": [ { - "deliveryStart": "2024-11-05T23:00:00Z", - "deliveryEnd": "2024-11-06T00:00:00Z", + "deliveryStart": "2025-10-01T22:00:00Z", + "deliveryEnd": "2025-10-01T22:15:00Z", "entryPerArea": { - "SE3": 126.66, - "SE4": 275.6 + "SE3": 933.22, + "SE4": 1062.32 } }, { - "deliveryStart": "2024-11-06T00:00:00Z", - "deliveryEnd": "2024-11-06T01:00:00Z", + "deliveryStart": "2025-10-01T22:15:00Z", + "deliveryEnd": "2025-10-01T22:30:00Z", "entryPerArea": { - "SE3": 74.06, - "SE4": 157.34 + "SE3": 854.22, + "SE4": 971.95 } }, { - "deliveryStart": "2024-11-06T01:00:00Z", - "deliveryEnd": "2024-11-06T02:00:00Z", + "deliveryStart": "2025-10-01T22:30:00Z", + "deliveryEnd": "2025-10-01T22:45:00Z", "entryPerArea": { - "SE3": 78.38, - "SE4": 165.62 + "SE3": 809.54, + "SE4": 919.1 } }, { - "deliveryStart": "2024-11-06T02:00:00Z", - "deliveryEnd": "2024-11-06T03:00:00Z", + "deliveryStart": "2025-10-01T22:45:00Z", + "deliveryEnd": "2025-10-01T23:00:00Z", "entryPerArea": { - "SE3": 92.37, - "SE4": 196.17 + "SE3": 811.74, + "SE4": 922.63 } }, { - "deliveryStart": "2024-11-06T03:00:00Z", - "deliveryEnd": "2024-11-06T04:00:00Z", + "deliveryStart": "2025-10-01T23:00:00Z", + "deliveryEnd": "2025-10-01T23:15:00Z", "entryPerArea": { - "SE3": 99.14, - "SE4": 190.58 + "SE3": 835.13, + "SE4": 950.99 } }, { - "deliveryStart": "2024-11-06T04:00:00Z", - "deliveryEnd": "2024-11-06T05:00:00Z", + "deliveryStart": "2025-10-01T23:15:00Z", + "deliveryEnd": "2025-10-01T23:30:00Z", "entryPerArea": { - "SE3": 447.51, - "SE4": 932.93 + "SE3": 828.85, + "SE4": 942.82 } }, { - "deliveryStart": "2024-11-06T05:00:00Z", - "deliveryEnd": "2024-11-06T06:00:00Z", + "deliveryStart": "2025-10-01T23:30:00Z", + "deliveryEnd": "2025-10-01T23:45:00Z", "entryPerArea": { - "SE3": 641.47, - "SE4": 1284.69 + "SE3": 796.63, + "SE4": 903.54 } }, { - "deliveryStart": "2024-11-06T06:00:00Z", - "deliveryEnd": "2024-11-06T07:00:00Z", + "deliveryStart": "2025-10-01T23:45:00Z", + "deliveryEnd": "2025-10-02T00:00:00Z", "entryPerArea": { - "SE3": 1820.5, - "SE4": 2449.96 + "SE3": 706.7, + "SE4": 799.61 } }, { - "deliveryStart": "2024-11-06T07:00:00Z", - "deliveryEnd": "2024-11-06T08:00:00Z", + "deliveryStart": "2025-10-02T00:00:00Z", + "deliveryEnd": "2025-10-02T00:15:00Z", "entryPerArea": { - "SE3": 1723.0, - "SE4": 2244.22 + "SE3": 695.23, + "SE4": 786.81 } }, { - "deliveryStart": "2024-11-06T08:00:00Z", - "deliveryEnd": "2024-11-06T09:00:00Z", + "deliveryStart": "2025-10-02T00:15:00Z", + "deliveryEnd": "2025-10-02T00:30:00Z", "entryPerArea": { - "SE3": 1298.57, - "SE4": 1643.45 + "SE3": 695.12, + "SE4": 783.83 } }, { - "deliveryStart": "2024-11-06T09:00:00Z", - "deliveryEnd": "2024-11-06T10:00:00Z", + "deliveryStart": "2025-10-02T00:30:00Z", + "deliveryEnd": "2025-10-02T00:45:00Z", "entryPerArea": { - "SE3": 1099.25, - "SE4": 1507.23 + "SE3": 684.86, + "SE4": 771.8 } }, { - "deliveryStart": "2024-11-06T10:00:00Z", - "deliveryEnd": "2024-11-06T11:00:00Z", + "deliveryStart": "2025-10-02T00:45:00Z", + "deliveryEnd": "2025-10-02T01:00:00Z", "entryPerArea": { - "SE3": 903.31, - "SE4": 1362.84 + "SE3": 673.05, + "SE4": 758.78 } }, { - "deliveryStart": "2024-11-06T11:00:00Z", - "deliveryEnd": "2024-11-06T12:00:00Z", + "deliveryStart": "2025-10-02T01:00:00Z", + "deliveryEnd": "2025-10-02T01:15:00Z", "entryPerArea": { - "SE3": 959.99, - "SE4": 1376.13 + "SE3": 695.01, + "SE4": 791.22 } }, { - "deliveryStart": "2024-11-06T12:00:00Z", - "deliveryEnd": "2024-11-06T13:00:00Z", + "deliveryStart": "2025-10-02T01:15:00Z", + "deliveryEnd": "2025-10-02T01:30:00Z", "entryPerArea": { - "SE3": 1186.61, - "SE4": 1449.96 + "SE3": 693.35, + "SE4": 789.12 } }, { - "deliveryStart": "2024-11-06T13:00:00Z", - "deliveryEnd": "2024-11-06T14:00:00Z", + "deliveryStart": "2025-10-02T01:30:00Z", + "deliveryEnd": "2025-10-02T01:45:00Z", "entryPerArea": { - "SE3": 1307.67, - "SE4": 1608.35 + "SE3": 702.4, + "SE4": 799.61 } }, { - "deliveryStart": "2024-11-06T14:00:00Z", - "deliveryEnd": "2024-11-06T15:00:00Z", + "deliveryStart": "2025-10-02T01:45:00Z", + "deliveryEnd": "2025-10-02T02:00:00Z", "entryPerArea": { - "SE3": 1385.46, - "SE4": 2110.8 + "SE3": 749.4, + "SE4": 853.45 } }, { - "deliveryStart": "2024-11-06T15:00:00Z", - "deliveryEnd": "2024-11-06T16:00:00Z", + "deliveryStart": "2025-10-02T02:00:00Z", + "deliveryEnd": "2025-10-02T02:15:00Z", "entryPerArea": { - "SE3": 1366.8, - "SE4": 3031.25 + "SE3": 796.85, + "SE4": 907.4 } }, { - "deliveryStart": "2024-11-06T16:00:00Z", - "deliveryEnd": "2024-11-06T17:00:00Z", + "deliveryStart": "2025-10-02T02:15:00Z", + "deliveryEnd": "2025-10-02T02:30:00Z", "entryPerArea": { - "SE3": 2366.57, - "SE4": 5511.77 + "SE3": 811.19, + "SE4": 924.07 } }, { - "deliveryStart": "2024-11-06T17:00:00Z", - "deliveryEnd": "2024-11-06T18:00:00Z", + "deliveryStart": "2025-10-02T02:30:00Z", + "deliveryEnd": "2025-10-02T02:45:00Z", "entryPerArea": { - "SE3": 1481.92, - "SE4": 3351.64 + "SE3": 803.8, + "SE4": 916.23 } }, { - "deliveryStart": "2024-11-06T18:00:00Z", - "deliveryEnd": "2024-11-06T19:00:00Z", + "deliveryStart": "2025-10-02T02:45:00Z", + "deliveryEnd": "2025-10-02T03:00:00Z", "entryPerArea": { - "SE3": 1082.69, - "SE4": 2484.95 + "SE3": 839.11, + "SE4": 953.3 } }, { - "deliveryStart": "2024-11-06T19:00:00Z", - "deliveryEnd": "2024-11-06T20:00:00Z", + "deliveryStart": "2025-10-02T03:00:00Z", + "deliveryEnd": "2025-10-02T03:15:00Z", "entryPerArea": { - "SE3": 716.82, - "SE4": 1624.33 + "SE3": 825.2, + "SE4": 943.15 } }, { - "deliveryStart": "2024-11-06T20:00:00Z", - "deliveryEnd": "2024-11-06T21:00:00Z", + "deliveryStart": "2025-10-02T03:15:00Z", + "deliveryEnd": "2025-10-02T03:30:00Z", "entryPerArea": { - "SE3": 583.16, - "SE4": 1306.27 + "SE3": 838.78, + "SE4": 958.93 } }, { - "deliveryStart": "2024-11-06T21:00:00Z", - "deliveryEnd": "2024-11-06T22:00:00Z", + "deliveryStart": "2025-10-02T03:30:00Z", + "deliveryEnd": "2025-10-02T03:45:00Z", "entryPerArea": { - "SE3": 523.09, - "SE4": 1142.99 + "SE3": 906.19, + "SE4": 1030.65 } }, { - "deliveryStart": "2024-11-06T22:00:00Z", - "deliveryEnd": "2024-11-06T23:00:00Z", + "deliveryStart": "2025-10-02T03:45:00Z", + "deliveryEnd": "2025-10-02T04:00:00Z", "entryPerArea": { - "SE3": 250.64, - "SE4": 539.42 + "SE3": 1057.79, + "SE4": 1195.82 + } + }, + { + "deliveryStart": "2025-10-02T04:00:00Z", + "deliveryEnd": "2025-10-02T04:15:00Z", + "entryPerArea": { + "SE3": 912.15, + "SE4": 1040.8 + } + }, + { + "deliveryStart": "2025-10-02T04:15:00Z", + "deliveryEnd": "2025-10-02T04:30:00Z", + "entryPerArea": { + "SE3": 1131.28, + "SE4": 1283.43 + } + }, + { + "deliveryStart": "2025-10-02T04:30:00Z", + "deliveryEnd": "2025-10-02T04:45:00Z", + "entryPerArea": { + "SE3": 1294.68, + "SE4": 1468.91 + } + }, + { + "deliveryStart": "2025-10-02T04:45:00Z", + "deliveryEnd": "2025-10-02T05:00:00Z", + "entryPerArea": { + "SE3": 1625.8, + "SE4": 1845.81 + } + }, + { + "deliveryStart": "2025-10-02T05:00:00Z", + "deliveryEnd": "2025-10-02T05:15:00Z", + "entryPerArea": { + "SE3": 1649.31, + "SE4": 1946.77 + } + }, + { + "deliveryStart": "2025-10-02T05:15:00Z", + "deliveryEnd": "2025-10-02T05:30:00Z", + "entryPerArea": { + "SE3": 1831.25, + "SE4": 2182.34 + } + }, + { + "deliveryStart": "2025-10-02T05:30:00Z", + "deliveryEnd": "2025-10-02T05:45:00Z", + "entryPerArea": { + "SE3": 1743.31, + "SE4": 2063.4 + } + }, + { + "deliveryStart": "2025-10-02T05:45:00Z", + "deliveryEnd": "2025-10-02T06:00:00Z", + "entryPerArea": { + "SE3": 1545.04, + "SE4": 1803.33 + } + }, + { + "deliveryStart": "2025-10-02T06:00:00Z", + "deliveryEnd": "2025-10-02T06:15:00Z", + "entryPerArea": { + "SE3": 1783.47, + "SE4": 2080.72 + } + }, + { + "deliveryStart": "2025-10-02T06:15:00Z", + "deliveryEnd": "2025-10-02T06:30:00Z", + "entryPerArea": { + "SE3": 1470.89, + "SE4": 1675.23 + } + }, + { + "deliveryStart": "2025-10-02T06:30:00Z", + "deliveryEnd": "2025-10-02T06:45:00Z", + "entryPerArea": { + "SE3": 1191.08, + "SE4": 1288.06 + } + }, + { + "deliveryStart": "2025-10-02T06:45:00Z", + "deliveryEnd": "2025-10-02T07:00:00Z", + "entryPerArea": { + "SE3": 1012.22, + "SE4": 1112.19 + } + }, + { + "deliveryStart": "2025-10-02T07:00:00Z", + "deliveryEnd": "2025-10-02T07:15:00Z", + "entryPerArea": { + "SE3": 1278.69, + "SE4": 1375.67 + } + }, + { + "deliveryStart": "2025-10-02T07:15:00Z", + "deliveryEnd": "2025-10-02T07:30:00Z", + "entryPerArea": { + "SE3": 1170.12, + "SE4": 1258.61 + } + }, + { + "deliveryStart": "2025-10-02T07:30:00Z", + "deliveryEnd": "2025-10-02T07:45:00Z", + "entryPerArea": { + "SE3": 937.09, + "SE4": 1021.93 + } + }, + { + "deliveryStart": "2025-10-02T07:45:00Z", + "deliveryEnd": "2025-10-02T08:00:00Z", + "entryPerArea": { + "SE3": 815.94, + "SE4": 900.67 + } + }, + { + "deliveryStart": "2025-10-02T08:00:00Z", + "deliveryEnd": "2025-10-02T08:15:00Z", + "entryPerArea": { + "SE3": 1044.66, + "SE4": 1135.25 + } + }, + { + "deliveryStart": "2025-10-02T08:15:00Z", + "deliveryEnd": "2025-10-02T08:30:00Z", + "entryPerArea": { + "SE3": 1020.61, + "SE4": 1112.74 + } + }, + { + "deliveryStart": "2025-10-02T08:30:00Z", + "deliveryEnd": "2025-10-02T08:45:00Z", + "entryPerArea": { + "SE3": 866.14, + "SE4": 953.53 + } + }, + { + "deliveryStart": "2025-10-02T08:45:00Z", + "deliveryEnd": "2025-10-02T09:00:00Z", + "entryPerArea": { + "SE3": 774.34, + "SE4": 860.18 + } + }, + { + "deliveryStart": "2025-10-02T09:00:00Z", + "deliveryEnd": "2025-10-02T09:15:00Z", + "entryPerArea": { + "SE3": 928.26, + "SE4": 1020.39 + } + }, + { + "deliveryStart": "2025-10-02T09:15:00Z", + "deliveryEnd": "2025-10-02T09:30:00Z", + "entryPerArea": { + "SE3": 834.47, + "SE4": 922.96 + } + }, + { + "deliveryStart": "2025-10-02T09:30:00Z", + "deliveryEnd": "2025-10-02T09:45:00Z", + "entryPerArea": { + "SE3": 712.33, + "SE4": 794.64 + } + }, + { + "deliveryStart": "2025-10-02T09:45:00Z", + "deliveryEnd": "2025-10-02T10:00:00Z", + "entryPerArea": { + "SE3": 646.46, + "SE4": 725.9 + } + }, + { + "deliveryStart": "2025-10-02T10:00:00Z", + "deliveryEnd": "2025-10-02T10:15:00Z", + "entryPerArea": { + "SE3": 692.91, + "SE4": 773.9 + } + }, + { + "deliveryStart": "2025-10-02T10:15:00Z", + "deliveryEnd": "2025-10-02T10:30:00Z", + "entryPerArea": { + "SE3": 627.59, + "SE4": 706.59 + } + }, + { + "deliveryStart": "2025-10-02T10:30:00Z", + "deliveryEnd": "2025-10-02T10:45:00Z", + "entryPerArea": { + "SE3": 630.02, + "SE4": 708.14 + } + }, + { + "deliveryStart": "2025-10-02T10:45:00Z", + "deliveryEnd": "2025-10-02T11:00:00Z", + "entryPerArea": { + "SE3": 625.94, + "SE4": 703.61 + } + }, + { + "deliveryStart": "2025-10-02T11:00:00Z", + "deliveryEnd": "2025-10-02T11:15:00Z", + "entryPerArea": { + "SE3": 563.38, + "SE4": 635.76 + } + }, + { + "deliveryStart": "2025-10-02T11:15:00Z", + "deliveryEnd": "2025-10-02T11:30:00Z", + "entryPerArea": { + "SE3": 588.42, + "SE4": 663.12 + } + }, + { + "deliveryStart": "2025-10-02T11:30:00Z", + "deliveryEnd": "2025-10-02T11:45:00Z", + "entryPerArea": { + "SE3": 597.03, + "SE4": 672.83 + } + }, + { + "deliveryStart": "2025-10-02T11:45:00Z", + "deliveryEnd": "2025-10-02T12:00:00Z", + "entryPerArea": { + "SE3": 608.61, + "SE4": 685.19 + } + }, + { + "deliveryStart": "2025-10-02T12:00:00Z", + "deliveryEnd": "2025-10-02T12:15:00Z", + "entryPerArea": { + "SE3": 599.24, + "SE4": 676.91 + } + }, + { + "deliveryStart": "2025-10-02T12:15:00Z", + "deliveryEnd": "2025-10-02T12:30:00Z", + "entryPerArea": { + "SE3": 649.77, + "SE4": 729.54 + } + }, + { + "deliveryStart": "2025-10-02T12:30:00Z", + "deliveryEnd": "2025-10-02T12:45:00Z", + "entryPerArea": { + "SE3": 728.22, + "SE4": 821.23 + } + }, + { + "deliveryStart": "2025-10-02T12:45:00Z", + "deliveryEnd": "2025-10-02T13:00:00Z", + "entryPerArea": { + "SE3": 803.91, + "SE4": 909.06 + } + }, + { + "deliveryStart": "2025-10-02T13:00:00Z", + "deliveryEnd": "2025-10-02T13:15:00Z", + "entryPerArea": { + "SE3": 594.38, + "SE4": 679.23 + } + }, + { + "deliveryStart": "2025-10-02T13:15:00Z", + "deliveryEnd": "2025-10-02T13:30:00Z", + "entryPerArea": { + "SE3": 738.48, + "SE4": 825.09 + } + }, + { + "deliveryStart": "2025-10-02T13:30:00Z", + "deliveryEnd": "2025-10-02T13:45:00Z", + "entryPerArea": { + "SE3": 873.53, + "SE4": 962.02 + } + }, + { + "deliveryStart": "2025-10-02T13:45:00Z", + "deliveryEnd": "2025-10-02T14:00:00Z", + "entryPerArea": { + "SE3": 994.57, + "SE4": 1083.5 + } + }, + { + "deliveryStart": "2025-10-02T14:00:00Z", + "deliveryEnd": "2025-10-02T14:15:00Z", + "entryPerArea": { + "SE3": 733.52, + "SE4": 813.18 + } + }, + { + "deliveryStart": "2025-10-02T14:15:00Z", + "deliveryEnd": "2025-10-02T14:30:00Z", + "entryPerArea": { + "SE3": 864.59, + "SE4": 944.04 + } + }, + { + "deliveryStart": "2025-10-02T14:30:00Z", + "deliveryEnd": "2025-10-02T14:45:00Z", + "entryPerArea": { + "SE3": 1032.08, + "SE4": 1113.18 + } + }, + { + "deliveryStart": "2025-10-02T14:45:00Z", + "deliveryEnd": "2025-10-02T15:00:00Z", + "entryPerArea": { + "SE3": 1153.01, + "SE4": 1241.61 + } + }, + { + "deliveryStart": "2025-10-02T15:00:00Z", + "deliveryEnd": "2025-10-02T15:15:00Z", + "entryPerArea": { + "SE3": 1271.18, + "SE4": 1017.41 + } + }, + { + "deliveryStart": "2025-10-02T15:15:00Z", + "deliveryEnd": "2025-10-02T15:30:00Z", + "entryPerArea": { + "SE3": 1375.23, + "SE4": 1093.1 + } + }, + { + "deliveryStart": "2025-10-02T15:30:00Z", + "deliveryEnd": "2025-10-02T15:45:00Z", + "entryPerArea": { + "SE3": 1544.82, + "SE4": 1244.81 + } + }, + { + "deliveryStart": "2025-10-02T15:45:00Z", + "deliveryEnd": "2025-10-02T16:00:00Z", + "entryPerArea": { + "SE3": 2412.17, + "SE4": 1960.12 + } + }, + { + "deliveryStart": "2025-10-02T16:00:00Z", + "deliveryEnd": "2025-10-02T16:15:00Z", + "entryPerArea": { + "SE3": 1677.66, + "SE4": 1334.3 + } + }, + { + "deliveryStart": "2025-10-02T16:15:00Z", + "deliveryEnd": "2025-10-02T16:30:00Z", + "entryPerArea": { + "SE3": 2010.55, + "SE4": 1606.61 + } + }, + { + "deliveryStart": "2025-10-02T16:30:00Z", + "deliveryEnd": "2025-10-02T16:45:00Z", + "entryPerArea": { + "SE3": 2524.38, + "SE4": 2013.53 + } + }, + { + "deliveryStart": "2025-10-02T16:45:00Z", + "deliveryEnd": "2025-10-02T17:00:00Z", + "entryPerArea": { + "SE3": 3288.35, + "SE4": 2617.73 + } + }, + { + "deliveryStart": "2025-10-02T17:00:00Z", + "deliveryEnd": "2025-10-02T17:15:00Z", + "entryPerArea": { + "SE3": 3065.69, + "SE4": 2472.19 + } + }, + { + "deliveryStart": "2025-10-02T17:15:00Z", + "deliveryEnd": "2025-10-02T17:30:00Z", + "entryPerArea": { + "SE3": 2824.72, + "SE4": 2276.46 + } + }, + { + "deliveryStart": "2025-10-02T17:30:00Z", + "deliveryEnd": "2025-10-02T17:45:00Z", + "entryPerArea": { + "SE3": 2279.66, + "SE4": 1835.44 + } + }, + { + "deliveryStart": "2025-10-02T17:45:00Z", + "deliveryEnd": "2025-10-02T18:00:00Z", + "entryPerArea": { + "SE3": 1723.78, + "SE4": 1385.38 + } + }, + { + "deliveryStart": "2025-10-02T18:00:00Z", + "deliveryEnd": "2025-10-02T18:15:00Z", + "entryPerArea": { + "SE3": 1935.08, + "SE4": 1532.57 + } + }, + { + "deliveryStart": "2025-10-02T18:15:00Z", + "deliveryEnd": "2025-10-02T18:30:00Z", + "entryPerArea": { + "SE3": 1568.54, + "SE4": 1240.18 + } + }, + { + "deliveryStart": "2025-10-02T18:30:00Z", + "deliveryEnd": "2025-10-02T18:45:00Z", + "entryPerArea": { + "SE3": 1430.51, + "SE4": 1115.61 + } + }, + { + "deliveryStart": "2025-10-02T18:45:00Z", + "deliveryEnd": "2025-10-02T19:00:00Z", + "entryPerArea": { + "SE3": 1377.66, + "SE4": 1075.12 + } + }, + { + "deliveryStart": "2025-10-02T19:00:00Z", + "deliveryEnd": "2025-10-02T19:15:00Z", + "entryPerArea": { + "SE3": 1408.44, + "SE4": 1108.66 + } + }, + { + "deliveryStart": "2025-10-02T19:15:00Z", + "deliveryEnd": "2025-10-02T19:30:00Z", + "entryPerArea": { + "SE3": 1326.79, + "SE4": 1049.74 + } + }, + { + "deliveryStart": "2025-10-02T19:30:00Z", + "deliveryEnd": "2025-10-02T19:45:00Z", + "entryPerArea": { + "SE3": 1210.94, + "SE4": 951.1 + } + }, + { + "deliveryStart": "2025-10-02T19:45:00Z", + "deliveryEnd": "2025-10-02T20:00:00Z", + "entryPerArea": { + "SE3": 1293.58, + "SE4": 1026.79 + } + }, + { + "deliveryStart": "2025-10-02T20:00:00Z", + "deliveryEnd": "2025-10-02T20:15:00Z", + "entryPerArea": { + "SE3": 1385.71, + "SE4": 1091.0 + } + }, + { + "deliveryStart": "2025-10-02T20:15:00Z", + "deliveryEnd": "2025-10-02T20:30:00Z", + "entryPerArea": { + "SE3": 1341.47, + "SE4": 1104.13 + } + }, + { + "deliveryStart": "2025-10-02T20:30:00Z", + "deliveryEnd": "2025-10-02T20:45:00Z", + "entryPerArea": { + "SE3": 1284.98, + "SE4": 1024.36 + } + }, + { + "deliveryStart": "2025-10-02T20:45:00Z", + "deliveryEnd": "2025-10-02T21:00:00Z", + "entryPerArea": { + "SE3": 1071.47, + "SE4": 892.51 + } + }, + { + "deliveryStart": "2025-10-02T21:00:00Z", + "deliveryEnd": "2025-10-02T21:15:00Z", + "entryPerArea": { + "SE3": 1218.0, + "SE4": 1123.99 + } + }, + { + "deliveryStart": "2025-10-02T21:15:00Z", + "deliveryEnd": "2025-10-02T21:30:00Z", + "entryPerArea": { + "SE3": 1112.3, + "SE4": 1001.63 + } + }, + { + "deliveryStart": "2025-10-02T21:30:00Z", + "deliveryEnd": "2025-10-02T21:45:00Z", + "entryPerArea": { + "SE3": 873.64, + "SE4": 806.67 + } + }, + { + "deliveryStart": "2025-10-02T21:45:00Z", + "deliveryEnd": "2025-10-02T22:00:00Z", + "entryPerArea": { + "SE3": 646.9, + "SE4": 591.84 } } ], "blockPriceAggregates": [ { "blockName": "Off-peak 1", - "deliveryStart": "2024-11-05T23:00:00Z", - "deliveryEnd": "2024-11-06T07:00:00Z", + "deliveryStart": "2025-10-01T22:00:00Z", + "deliveryEnd": "2025-10-02T06:00:00Z", "averagePricePerArea": { "SE3": { - "average": 422.51, - "min": 74.06, - "max": 1820.5 + "average": 961.76, + "min": 673.05, + "max": 1831.25 }, "SE4": { - "average": 706.61, - "min": 157.34, - "max": 2449.96 + "average": 1102.25, + "min": 758.78, + "max": 2182.34 } } }, { "blockName": "Peak", - "deliveryStart": "2024-11-06T07:00:00Z", - "deliveryEnd": "2024-11-06T19:00:00Z", + "deliveryStart": "2025-10-02T06:00:00Z", + "deliveryEnd": "2025-10-02T18:00:00Z", "averagePricePerArea": { "SE3": { - "average": 1346.82, - "min": 903.31, - "max": 2366.57 + "average": 1191.34, + "min": 563.38, + "max": 3288.35 }, "SE4": { - "average": 2306.88, - "min": 1362.84, - "max": 5511.77 + "average": 1155.07, + "min": 635.76, + "max": 2617.73 } } }, { "blockName": "Off-peak 2", - "deliveryStart": "2024-11-06T19:00:00Z", - "deliveryEnd": "2024-11-06T23:00:00Z", + "deliveryStart": "2025-10-02T18:00:00Z", + "deliveryEnd": "2025-10-02T22:00:00Z", "averagePricePerArea": { "SE3": { - "average": 518.43, - "min": 250.64, - "max": 716.82 + "average": 1280.38, + "min": 646.9, + "max": 1935.08 }, "SE4": { - "average": 1153.25, - "min": 539.42, - "max": 1624.33 + "average": 1045.99, + "min": 591.84, + "max": 1532.57 } } } ], "currency": "SEK", - "exchangeRate": 11.66314, + "exchangeRate": 11.03362, "areaStates": [ { "state": "Final", @@ -262,11 +838,11 @@ "areaAverages": [ { "areaCode": "SE3", - "price": 900.65 + "price": 1129.65 }, { "areaCode": "SE4", - "price": 1581.19 + "price": 1119.28 } ] } diff --git a/tests/components/nordpool/fixtures/delivery_period_yesterday.json b/tests/components/nordpool/fixtures/delivery_period_yesterday.json index bc79aeb99f0..16af0a56934 100644 --- a/tests/components/nordpool/fixtures/delivery_period_yesterday.json +++ b/tests/components/nordpool/fixtures/delivery_period_yesterday.json @@ -1,258 +1,258 @@ { - "deliveryDateCET": "2024-11-04", + "deliveryDateCET": "2025-09-30", "version": 3, - "updatedAt": "2024-11-04T08:09:11.1931991Z", + "updatedAt": "2025-09-29T11:17:12.3019385Z", "deliveryAreas": ["SE3", "SE4"], "market": "DayAhead", "multiAreaEntries": [ { - "deliveryStart": "2024-11-03T23:00:00Z", - "deliveryEnd": "2024-11-04T00:00:00Z", + "deliveryStart": "2025-09-29T22:00:00Z", + "deliveryEnd": "2025-09-29T23:00:00Z", "entryPerArea": { - "SE3": 66.13, - "SE4": 78.59 + "SE3": 278.63, + "SE4": 354.65 } }, { - "deliveryStart": "2024-11-04T00:00:00Z", - "deliveryEnd": "2024-11-04T01:00:00Z", + "deliveryStart": "2025-09-29T23:00:00Z", + "deliveryEnd": "2025-09-30T00:00:00Z", "entryPerArea": { - "SE3": 72.54, - "SE4": 86.51 + "SE3": 261.85, + "SE4": 336.89 } }, { - "deliveryStart": "2024-11-04T01:00:00Z", - "deliveryEnd": "2024-11-04T02:00:00Z", + "deliveryStart": "2025-09-30T00:00:00Z", + "deliveryEnd": "2025-09-30T01:00:00Z", "entryPerArea": { - "SE3": 73.12, - "SE4": 84.88 + "SE3": 242.43, + "SE4": 313.16 } }, { - "deliveryStart": "2024-11-04T02:00:00Z", - "deliveryEnd": "2024-11-04T03:00:00Z", + "deliveryStart": "2025-09-30T01:00:00Z", + "deliveryEnd": "2025-09-30T02:00:00Z", "entryPerArea": { - "SE3": 171.97, - "SE4": 217.26 + "SE3": 322.65, + "SE4": 401.0 } }, { - "deliveryStart": "2024-11-04T03:00:00Z", - "deliveryEnd": "2024-11-04T04:00:00Z", + "deliveryStart": "2025-09-30T02:00:00Z", + "deliveryEnd": "2025-09-30T03:00:00Z", "entryPerArea": { - "SE3": 181.05, - "SE4": 227.74 + "SE3": 243.2, + "SE4": 311.51 } }, { - "deliveryStart": "2024-11-04T04:00:00Z", - "deliveryEnd": "2024-11-04T05:00:00Z", + "deliveryStart": "2025-09-30T03:00:00Z", + "deliveryEnd": "2025-09-30T04:00:00Z", "entryPerArea": { - "SE3": 360.71, - "SE4": 414.61 + "SE3": 596.53, + "SE4": 695.52 } }, { - "deliveryStart": "2024-11-04T05:00:00Z", - "deliveryEnd": "2024-11-04T06:00:00Z", + "deliveryStart": "2025-09-30T04:00:00Z", + "deliveryEnd": "2025-09-30T05:00:00Z", "entryPerArea": { - "SE3": 917.83, - "SE4": 1439.33 + "SE3": 899.77, + "SE4": 1047.52 } }, { - "deliveryStart": "2024-11-04T06:00:00Z", - "deliveryEnd": "2024-11-04T07:00:00Z", + "deliveryStart": "2025-09-30T05:00:00Z", + "deliveryEnd": "2025-09-30T06:00:00Z", "entryPerArea": { - "SE3": 1426.17, - "SE4": 1695.95 + "SE3": 1909.0, + "SE4": 2247.98 } }, { - "deliveryStart": "2024-11-04T07:00:00Z", - "deliveryEnd": "2024-11-04T08:00:00Z", + "deliveryStart": "2025-09-30T06:00:00Z", + "deliveryEnd": "2025-09-30T07:00:00Z", "entryPerArea": { - "SE3": 1350.96, - "SE4": 1605.13 + "SE3": 1432.52, + "SE4": 1681.24 } }, { - "deliveryStart": "2024-11-04T08:00:00Z", - "deliveryEnd": "2024-11-04T09:00:00Z", + "deliveryStart": "2025-09-30T07:00:00Z", + "deliveryEnd": "2025-09-30T08:00:00Z", "entryPerArea": { - "SE3": 1195.06, - "SE4": 1393.46 + "SE3": 1127.52, + "SE4": 1304.96 } }, { - "deliveryStart": "2024-11-04T09:00:00Z", - "deliveryEnd": "2024-11-04T10:00:00Z", + "deliveryStart": "2025-09-30T08:00:00Z", + "deliveryEnd": "2025-09-30T09:00:00Z", "entryPerArea": { - "SE3": 992.35, - "SE4": 1126.71 + "SE3": 966.75, + "SE4": 1073.34 } }, { - "deliveryStart": "2024-11-04T10:00:00Z", - "deliveryEnd": "2024-11-04T11:00:00Z", + "deliveryStart": "2025-09-30T09:00:00Z", + "deliveryEnd": "2025-09-30T10:00:00Z", "entryPerArea": { - "SE3": 976.63, - "SE4": 1107.97 + "SE3": 882.55, + "SE4": 1003.93 } }, { - "deliveryStart": "2024-11-04T11:00:00Z", - "deliveryEnd": "2024-11-04T12:00:00Z", + "deliveryStart": "2025-09-30T10:00:00Z", + "deliveryEnd": "2025-09-30T11:00:00Z", "entryPerArea": { - "SE3": 952.76, - "SE4": 1085.73 + "SE3": 841.72, + "SE4": 947.44 } }, { - "deliveryStart": "2024-11-04T12:00:00Z", - "deliveryEnd": "2024-11-04T13:00:00Z", + "deliveryStart": "2025-09-30T11:00:00Z", + "deliveryEnd": "2025-09-30T12:00:00Z", "entryPerArea": { - "SE3": 1029.37, - "SE4": 1177.71 + "SE3": 821.53, + "SE4": 927.24 } }, { - "deliveryStart": "2024-11-04T13:00:00Z", - "deliveryEnd": "2024-11-04T14:00:00Z", + "deliveryStart": "2025-09-30T12:00:00Z", + "deliveryEnd": "2025-09-30T13:00:00Z", "entryPerArea": { - "SE3": 1043.35, - "SE4": 1194.59 + "SE3": 864.35, + "SE4": 970.5 } }, { - "deliveryStart": "2024-11-04T14:00:00Z", - "deliveryEnd": "2024-11-04T15:00:00Z", + "deliveryStart": "2025-09-30T13:00:00Z", + "deliveryEnd": "2025-09-30T14:00:00Z", "entryPerArea": { - "SE3": 1359.57, - "SE4": 1561.12 + "SE3": 931.88, + "SE4": 1046.64 } }, { - "deliveryStart": "2024-11-04T15:00:00Z", - "deliveryEnd": "2024-11-04T16:00:00Z", + "deliveryStart": "2025-09-30T14:00:00Z", + "deliveryEnd": "2025-09-30T15:00:00Z", "entryPerArea": { - "SE3": 1848.35, - "SE4": 2145.84 + "SE3": 1039.13, + "SE4": 1165.04 } }, { - "deliveryStart": "2024-11-04T16:00:00Z", - "deliveryEnd": "2024-11-04T17:00:00Z", + "deliveryStart": "2025-09-30T15:00:00Z", + "deliveryEnd": "2025-09-30T16:00:00Z", "entryPerArea": { - "SE3": 2812.53, - "SE4": 3313.53 + "SE3": 1296.57, + "SE4": 1520.91 } }, { - "deliveryStart": "2024-11-04T17:00:00Z", - "deliveryEnd": "2024-11-04T18:00:00Z", + "deliveryStart": "2025-09-30T16:00:00Z", + "deliveryEnd": "2025-09-30T17:00:00Z", "entryPerArea": { - "SE3": 2351.69, - "SE4": 2751.87 + "SE3": 2652.18, + "SE4": 3083.2 } }, { - "deliveryStart": "2024-11-04T18:00:00Z", - "deliveryEnd": "2024-11-04T19:00:00Z", + "deliveryStart": "2025-09-30T17:00:00Z", + "deliveryEnd": "2025-09-30T18:00:00Z", "entryPerArea": { - "SE3": 1553.08, - "SE4": 1842.77 + "SE3": 2135.98, + "SE4": 2552.32 } }, { - "deliveryStart": "2024-11-04T19:00:00Z", - "deliveryEnd": "2024-11-04T20:00:00Z", + "deliveryStart": "2025-09-30T18:00:00Z", + "deliveryEnd": "2025-09-30T19:00:00Z", "entryPerArea": { - "SE3": 1165.02, - "SE4": 1398.35 + "SE3": 1109.76, + "SE4": 1305.73 } }, { - "deliveryStart": "2024-11-04T20:00:00Z", - "deliveryEnd": "2024-11-04T21:00:00Z", + "deliveryStart": "2025-09-30T19:00:00Z", + "deliveryEnd": "2025-09-30T20:00:00Z", "entryPerArea": { - "SE3": 1007.48, - "SE4": 1172.35 + "SE3": 973.81, + "SE4": 1130.83 } }, { - "deliveryStart": "2024-11-04T21:00:00Z", - "deliveryEnd": "2024-11-04T22:00:00Z", + "deliveryStart": "2025-09-30T20:00:00Z", + "deliveryEnd": "2025-09-30T21:00:00Z", "entryPerArea": { - "SE3": 792.09, - "SE4": 920.28 + "SE3": 872.18, + "SE4": 1019.05 } }, { - "deliveryStart": "2024-11-04T22:00:00Z", - "deliveryEnd": "2024-11-04T23:00:00Z", + "deliveryStart": "2025-09-30T21:00:00Z", + "deliveryEnd": "2025-09-30T22:00:00Z", "entryPerArea": { - "SE3": 465.38, - "SE4": 528.83 + "SE3": 697.17, + "SE4": 812.37 } } ], "blockPriceAggregates": [ { "blockName": "Off-peak 1", - "deliveryStart": "2024-11-03T23:00:00Z", - "deliveryEnd": "2024-11-04T07:00:00Z", + "deliveryStart": "2025-09-29T22:00:00Z", + "deliveryEnd": "2025-09-30T06:00:00Z", "averagePricePerArea": { "SE3": { - "average": 408.69, - "min": 66.13, - "max": 1426.17 + "average": 594.26, + "min": 242.43, + "max": 1909.0 }, "SE4": { - "average": 530.61, - "min": 78.59, - "max": 1695.95 + "average": 713.53, + "min": 311.51, + "max": 2247.98 } } }, { "blockName": "Peak", - "deliveryStart": "2024-11-04T07:00:00Z", - "deliveryEnd": "2024-11-04T19:00:00Z", + "deliveryStart": "2025-09-30T06:00:00Z", + "deliveryEnd": "2025-09-30T18:00:00Z", "averagePricePerArea": { "SE3": { - "average": 1455.48, - "min": 952.76, - "max": 2812.53 + "average": 1249.39, + "min": 821.53, + "max": 2652.18 }, "SE4": { - "average": 1692.2, - "min": 1085.73, - "max": 3313.53 + "average": 1439.73, + "min": 927.24, + "max": 3083.2 } } }, { "blockName": "Off-peak 2", - "deliveryStart": "2024-11-04T19:00:00Z", - "deliveryEnd": "2024-11-04T23:00:00Z", + "deliveryStart": "2025-09-30T18:00:00Z", + "deliveryEnd": "2025-09-30T22:00:00Z", "averagePricePerArea": { "SE3": { - "average": 857.49, - "min": 465.38, - "max": 1165.02 + "average": 913.23, + "min": 697.17, + "max": 1109.76 }, "SE4": { - "average": 1004.95, - "min": 528.83, - "max": 1398.35 + "average": 1067.0, + "min": 812.37, + "max": 1305.73 } } } ], "currency": "SEK", - "exchangeRate": 11.64318, + "exchangeRate": 11.03467, "areaStates": [ { "state": "Final", @@ -262,11 +262,11 @@ "areaAverages": [ { "areaCode": "SE3", - "price": 1006.88 + "price": 974.99 }, { "areaCode": "SE4", - "price": 1190.46 + "price": 1135.54 } ] } diff --git a/tests/components/nordpool/fixtures/indices_15.json b/tests/components/nordpool/fixtures/indices_15.json index 63af9840098..0af23104104 100644 --- a/tests/components/nordpool/fixtures/indices_15.json +++ b/tests/components/nordpool/fixtures/indices_15.json @@ -1,688 +1,688 @@ { - "deliveryDateCET": "2025-07-06", - "version": 2, - "updatedAt": "2025-07-05T10:56:42.3755929Z", + "deliveryDateCET": "2025-10-01", + "version": 3, + "updatedAt": "2025-09-30T12:08:18.4894194Z", "market": "DayAhead", "indexNames": ["SE3"], "currency": "SEK", "resolutionInMinutes": 15, "areaStates": [ { - "state": "Preliminary", + "state": "Final", "areas": ["SE3"] } ], "multiIndexEntries": [ { - "deliveryStart": "2025-07-05T22:00:00Z", - "deliveryEnd": "2025-07-05T22:15:00Z", + "deliveryStart": "2025-09-30T22:00:00Z", + "deliveryEnd": "2025-09-30T22:15:00Z", "entryPerArea": { - "SE3": 43.57 + "SE3": 556.68 } }, { - "deliveryStart": "2025-07-05T22:15:00Z", - "deliveryEnd": "2025-07-05T22:30:00Z", + "deliveryStart": "2025-09-30T22:15:00Z", + "deliveryEnd": "2025-09-30T22:30:00Z", "entryPerArea": { - "SE3": 43.57 + "SE3": 519.88 } }, { - "deliveryStart": "2025-07-05T22:30:00Z", - "deliveryEnd": "2025-07-05T22:45:00Z", + "deliveryStart": "2025-09-30T22:30:00Z", + "deliveryEnd": "2025-09-30T22:45:00Z", "entryPerArea": { - "SE3": 43.57 + "SE3": 508.28 } }, { - "deliveryStart": "2025-07-05T22:45:00Z", - "deliveryEnd": "2025-07-05T23:00:00Z", + "deliveryStart": "2025-09-30T22:45:00Z", + "deliveryEnd": "2025-09-30T23:00:00Z", "entryPerArea": { - "SE3": 43.57 + "SE3": 509.93 } }, { - "deliveryStart": "2025-07-05T23:00:00Z", - "deliveryEnd": "2025-07-05T23:15:00Z", + "deliveryStart": "2025-09-30T23:00:00Z", + "deliveryEnd": "2025-09-30T23:15:00Z", "entryPerArea": { - "SE3": 36.47 + "SE3": 501.64 } }, { - "deliveryStart": "2025-07-05T23:15:00Z", - "deliveryEnd": "2025-07-05T23:30:00Z", + "deliveryStart": "2025-09-30T23:15:00Z", + "deliveryEnd": "2025-09-30T23:30:00Z", "entryPerArea": { - "SE3": 36.47 + "SE3": 509.05 } }, { - "deliveryStart": "2025-07-05T23:30:00Z", - "deliveryEnd": "2025-07-05T23:45:00Z", + "deliveryStart": "2025-09-30T23:30:00Z", + "deliveryEnd": "2025-09-30T23:45:00Z", "entryPerArea": { - "SE3": 36.47 + "SE3": 491.03 } }, { - "deliveryStart": "2025-07-05T23:45:00Z", - "deliveryEnd": "2025-07-06T00:00:00Z", + "deliveryStart": "2025-09-30T23:45:00Z", + "deliveryEnd": "2025-10-01T00:00:00Z", "entryPerArea": { - "SE3": 36.47 + "SE3": 442.07 } }, { - "deliveryStart": "2025-07-06T00:00:00Z", - "deliveryEnd": "2025-07-06T00:15:00Z", + "deliveryStart": "2025-10-01T00:00:00Z", + "deliveryEnd": "2025-10-01T00:15:00Z", "entryPerArea": { - "SE3": 35.57 + "SE3": 504.08 } }, { - "deliveryStart": "2025-07-06T00:15:00Z", - "deliveryEnd": "2025-07-06T00:30:00Z", + "deliveryStart": "2025-10-01T00:15:00Z", + "deliveryEnd": "2025-10-01T00:30:00Z", "entryPerArea": { - "SE3": 35.57 + "SE3": 504.85 } }, { - "deliveryStart": "2025-07-06T00:30:00Z", - "deliveryEnd": "2025-07-06T00:45:00Z", + "deliveryStart": "2025-10-01T00:30:00Z", + "deliveryEnd": "2025-10-01T00:45:00Z", "entryPerArea": { - "SE3": 35.57 + "SE3": 504.3 } }, { - "deliveryStart": "2025-07-06T00:45:00Z", - "deliveryEnd": "2025-07-06T01:00:00Z", + "deliveryStart": "2025-10-01T00:45:00Z", + "deliveryEnd": "2025-10-01T01:00:00Z", "entryPerArea": { - "SE3": 35.57 + "SE3": 506.29 } }, { - "deliveryStart": "2025-07-06T01:00:00Z", - "deliveryEnd": "2025-07-06T01:15:00Z", + "deliveryStart": "2025-10-01T01:00:00Z", + "deliveryEnd": "2025-10-01T01:15:00Z", "entryPerArea": { - "SE3": 30.73 + "SE3": 442.07 } }, { - "deliveryStart": "2025-07-06T01:15:00Z", - "deliveryEnd": "2025-07-06T01:30:00Z", + "deliveryStart": "2025-10-01T01:15:00Z", + "deliveryEnd": "2025-10-01T01:30:00Z", "entryPerArea": { - "SE3": 30.73 + "SE3": 441.96 } }, { - "deliveryStart": "2025-07-06T01:30:00Z", - "deliveryEnd": "2025-07-06T01:45:00Z", + "deliveryStart": "2025-10-01T01:30:00Z", + "deliveryEnd": "2025-10-01T01:45:00Z", "entryPerArea": { - "SE3": 30.73 + "SE3": 442.07 } }, { - "deliveryStart": "2025-07-06T01:45:00Z", - "deliveryEnd": "2025-07-06T02:00:00Z", + "deliveryStart": "2025-10-01T01:45:00Z", + "deliveryEnd": "2025-10-01T02:00:00Z", "entryPerArea": { - "SE3": 30.73 + "SE3": 442.07 } }, { - "deliveryStart": "2025-07-06T02:00:00Z", - "deliveryEnd": "2025-07-06T02:15:00Z", + "deliveryStart": "2025-10-01T02:00:00Z", + "deliveryEnd": "2025-10-01T02:15:00Z", "entryPerArea": { - "SE3": 32.42 + "SE3": 441.96 } }, { - "deliveryStart": "2025-07-06T02:15:00Z", - "deliveryEnd": "2025-07-06T02:30:00Z", + "deliveryStart": "2025-10-01T02:15:00Z", + "deliveryEnd": "2025-10-01T02:30:00Z", "entryPerArea": { - "SE3": 32.42 + "SE3": 483.3 } }, { - "deliveryStart": "2025-07-06T02:30:00Z", - "deliveryEnd": "2025-07-06T02:45:00Z", + "deliveryStart": "2025-10-01T02:30:00Z", + "deliveryEnd": "2025-10-01T02:45:00Z", "entryPerArea": { - "SE3": 32.42 + "SE3": 484.29 } }, { - "deliveryStart": "2025-07-06T02:45:00Z", - "deliveryEnd": "2025-07-06T03:00:00Z", + "deliveryStart": "2025-10-01T02:45:00Z", + "deliveryEnd": "2025-10-01T03:00:00Z", "entryPerArea": { - "SE3": 32.42 + "SE3": 574.7 } }, { - "deliveryStart": "2025-07-06T03:00:00Z", - "deliveryEnd": "2025-07-06T03:15:00Z", + "deliveryStart": "2025-10-01T03:00:00Z", + "deliveryEnd": "2025-10-01T03:15:00Z", "entryPerArea": { - "SE3": 38.73 + "SE3": 543.31 } }, { - "deliveryStart": "2025-07-06T03:15:00Z", - "deliveryEnd": "2025-07-06T03:30:00Z", + "deliveryStart": "2025-10-01T03:15:00Z", + "deliveryEnd": "2025-10-01T03:30:00Z", "entryPerArea": { - "SE3": 38.73 + "SE3": 578.01 } }, { - "deliveryStart": "2025-07-06T03:30:00Z", - "deliveryEnd": "2025-07-06T03:45:00Z", + "deliveryStart": "2025-10-01T03:30:00Z", + "deliveryEnd": "2025-10-01T03:45:00Z", "entryPerArea": { - "SE3": 38.73 + "SE3": 774.96 } }, { - "deliveryStart": "2025-07-06T03:45:00Z", - "deliveryEnd": "2025-07-06T04:00:00Z", + "deliveryStart": "2025-10-01T03:45:00Z", + "deliveryEnd": "2025-10-01T04:00:00Z", "entryPerArea": { - "SE3": 38.73 + "SE3": 787.0 } }, { - "deliveryStart": "2025-07-06T04:00:00Z", - "deliveryEnd": "2025-07-06T04:15:00Z", + "deliveryStart": "2025-10-01T04:00:00Z", + "deliveryEnd": "2025-10-01T04:15:00Z", "entryPerArea": { - "SE3": 42.78 + "SE3": 902.38 } }, { - "deliveryStart": "2025-07-06T04:15:00Z", - "deliveryEnd": "2025-07-06T04:30:00Z", + "deliveryStart": "2025-10-01T04:15:00Z", + "deliveryEnd": "2025-10-01T04:30:00Z", "entryPerArea": { - "SE3": 42.78 + "SE3": 1079.32 } }, { - "deliveryStart": "2025-07-06T04:30:00Z", - "deliveryEnd": "2025-07-06T04:45:00Z", + "deliveryStart": "2025-10-01T04:30:00Z", + "deliveryEnd": "2025-10-01T04:45:00Z", "entryPerArea": { - "SE3": 42.78 + "SE3": 1222.67 } }, { - "deliveryStart": "2025-07-06T04:45:00Z", - "deliveryEnd": "2025-07-06T05:00:00Z", + "deliveryStart": "2025-10-01T04:45:00Z", + "deliveryEnd": "2025-10-01T05:00:00Z", "entryPerArea": { - "SE3": 42.78 + "SE3": 1394.63 } }, { - "deliveryStart": "2025-07-06T05:00:00Z", - "deliveryEnd": "2025-07-06T05:15:00Z", + "deliveryStart": "2025-10-01T05:00:00Z", + "deliveryEnd": "2025-10-01T05:15:00Z", "entryPerArea": { - "SE3": 54.71 + "SE3": 1529.36 } }, { - "deliveryStart": "2025-07-06T05:15:00Z", - "deliveryEnd": "2025-07-06T05:30:00Z", + "deliveryStart": "2025-10-01T05:15:00Z", + "deliveryEnd": "2025-10-01T05:30:00Z", "entryPerArea": { - "SE3": 54.71 + "SE3": 1724.53 } }, { - "deliveryStart": "2025-07-06T05:30:00Z", - "deliveryEnd": "2025-07-06T05:45:00Z", + "deliveryStart": "2025-10-01T05:30:00Z", + "deliveryEnd": "2025-10-01T05:45:00Z", "entryPerArea": { - "SE3": 54.71 + "SE3": 1809.96 } }, { - "deliveryStart": "2025-07-06T05:45:00Z", - "deliveryEnd": "2025-07-06T06:00:00Z", + "deliveryStart": "2025-10-01T05:45:00Z", + "deliveryEnd": "2025-10-01T06:00:00Z", "entryPerArea": { - "SE3": 54.71 + "SE3": 1713.04 } }, { - "deliveryStart": "2025-07-06T06:00:00Z", - "deliveryEnd": "2025-07-06T06:15:00Z", + "deliveryStart": "2025-10-01T06:00:00Z", + "deliveryEnd": "2025-10-01T06:15:00Z", "entryPerArea": { - "SE3": 83.87 + "SE3": 1925.9 } }, { - "deliveryStart": "2025-07-06T06:15:00Z", - "deliveryEnd": "2025-07-06T06:30:00Z", + "deliveryStart": "2025-10-01T06:15:00Z", + "deliveryEnd": "2025-10-01T06:30:00Z", "entryPerArea": { - "SE3": 83.87 + "SE3": 1440.06 } }, { - "deliveryStart": "2025-07-06T06:30:00Z", - "deliveryEnd": "2025-07-06T06:45:00Z", + "deliveryStart": "2025-10-01T06:30:00Z", + "deliveryEnd": "2025-10-01T06:45:00Z", "entryPerArea": { - "SE3": 83.87 + "SE3": 1183.32 } }, { - "deliveryStart": "2025-07-06T06:45:00Z", - "deliveryEnd": "2025-07-06T07:00:00Z", + "deliveryStart": "2025-10-01T06:45:00Z", + "deliveryEnd": "2025-10-01T07:00:00Z", "entryPerArea": { - "SE3": 83.87 + "SE3": 962.95 } }, { - "deliveryStart": "2025-07-06T07:00:00Z", - "deliveryEnd": "2025-07-06T07:15:00Z", + "deliveryStart": "2025-10-01T07:00:00Z", + "deliveryEnd": "2025-10-01T07:15:00Z", "entryPerArea": { - "SE3": 78.8 + "SE3": 1402.04 } }, { - "deliveryStart": "2025-07-06T07:15:00Z", - "deliveryEnd": "2025-07-06T07:30:00Z", + "deliveryStart": "2025-10-01T07:15:00Z", + "deliveryEnd": "2025-10-01T07:30:00Z", "entryPerArea": { - "SE3": 78.8 + "SE3": 1060.65 } }, { - "deliveryStart": "2025-07-06T07:30:00Z", - "deliveryEnd": "2025-07-06T07:45:00Z", + "deliveryStart": "2025-10-01T07:30:00Z", + "deliveryEnd": "2025-10-01T07:45:00Z", "entryPerArea": { - "SE3": 78.8 + "SE3": 949.13 } }, { - "deliveryStart": "2025-07-06T07:45:00Z", - "deliveryEnd": "2025-07-06T08:00:00Z", + "deliveryStart": "2025-10-01T07:45:00Z", + "deliveryEnd": "2025-10-01T08:00:00Z", "entryPerArea": { - "SE3": 78.8 + "SE3": 841.82 } }, { - "deliveryStart": "2025-07-06T08:00:00Z", - "deliveryEnd": "2025-07-06T08:15:00Z", + "deliveryStart": "2025-10-01T08:00:00Z", + "deliveryEnd": "2025-10-01T08:15:00Z", "entryPerArea": { - "SE3": 92.09 + "SE3": 1037.44 } }, { - "deliveryStart": "2025-07-06T08:15:00Z", - "deliveryEnd": "2025-07-06T08:30:00Z", + "deliveryStart": "2025-10-01T08:15:00Z", + "deliveryEnd": "2025-10-01T08:30:00Z", "entryPerArea": { - "SE3": 92.09 + "SE3": 950.13 } }, { - "deliveryStart": "2025-07-06T08:30:00Z", - "deliveryEnd": "2025-07-06T08:45:00Z", + "deliveryStart": "2025-10-01T08:30:00Z", + "deliveryEnd": "2025-10-01T08:45:00Z", "entryPerArea": { - "SE3": 92.09 + "SE3": 826.13 } }, { - "deliveryStart": "2025-07-06T08:45:00Z", - "deliveryEnd": "2025-07-06T09:00:00Z", + "deliveryStart": "2025-10-01T08:45:00Z", + "deliveryEnd": "2025-10-01T09:00:00Z", "entryPerArea": { - "SE3": 92.09 + "SE3": 684.55 } }, { - "deliveryStart": "2025-07-06T09:00:00Z", - "deliveryEnd": "2025-07-06T09:15:00Z", + "deliveryStart": "2025-10-01T09:00:00Z", + "deliveryEnd": "2025-10-01T09:15:00Z", "entryPerArea": { - "SE3": 104.92 + "SE3": 861.6 } }, { - "deliveryStart": "2025-07-06T09:15:00Z", - "deliveryEnd": "2025-07-06T09:30:00Z", + "deliveryStart": "2025-10-01T09:15:00Z", + "deliveryEnd": "2025-10-01T09:30:00Z", "entryPerArea": { - "SE3": 104.92 + "SE3": 722.79 } }, { - "deliveryStart": "2025-07-06T09:30:00Z", - "deliveryEnd": "2025-07-06T09:45:00Z", + "deliveryStart": "2025-10-01T09:30:00Z", + "deliveryEnd": "2025-10-01T09:45:00Z", "entryPerArea": { - "SE3": 104.92 + "SE3": 640.57 } }, { - "deliveryStart": "2025-07-06T09:45:00Z", - "deliveryEnd": "2025-07-06T10:00:00Z", + "deliveryStart": "2025-10-01T09:45:00Z", + "deliveryEnd": "2025-10-01T10:00:00Z", "entryPerArea": { - "SE3": 104.92 + "SE3": 607.74 } }, { - "deliveryStart": "2025-07-06T10:00:00Z", - "deliveryEnd": "2025-07-06T10:15:00Z", + "deliveryStart": "2025-10-01T10:00:00Z", + "deliveryEnd": "2025-10-01T10:15:00Z", "entryPerArea": { - "SE3": 72.5 + "SE3": 674.05 } }, { - "deliveryStart": "2025-07-06T10:15:00Z", - "deliveryEnd": "2025-07-06T10:30:00Z", + "deliveryStart": "2025-10-01T10:15:00Z", + "deliveryEnd": "2025-10-01T10:30:00Z", "entryPerArea": { - "SE3": 72.5 + "SE3": 638.58 } }, { - "deliveryStart": "2025-07-06T10:30:00Z", - "deliveryEnd": "2025-07-06T10:45:00Z", + "deliveryStart": "2025-10-01T10:30:00Z", + "deliveryEnd": "2025-10-01T10:45:00Z", "entryPerArea": { - "SE3": 72.5 + "SE3": 638.47 } }, { - "deliveryStart": "2025-07-06T10:45:00Z", - "deliveryEnd": "2025-07-06T11:00:00Z", + "deliveryStart": "2025-10-01T10:45:00Z", + "deliveryEnd": "2025-10-01T11:00:00Z", "entryPerArea": { - "SE3": 72.5 + "SE3": 634.82 } }, { - "deliveryStart": "2025-07-06T11:00:00Z", - "deliveryEnd": "2025-07-06T11:15:00Z", + "deliveryStart": "2025-10-01T11:00:00Z", + "deliveryEnd": "2025-10-01T11:15:00Z", "entryPerArea": { - "SE3": 63.49 + "SE3": 637.36 } }, { - "deliveryStart": "2025-07-06T11:15:00Z", - "deliveryEnd": "2025-07-06T11:30:00Z", + "deliveryStart": "2025-10-01T11:15:00Z", + "deliveryEnd": "2025-10-01T11:30:00Z", "entryPerArea": { - "SE3": 63.49 + "SE3": 660.68 } }, { - "deliveryStart": "2025-07-06T11:30:00Z", - "deliveryEnd": "2025-07-06T11:45:00Z", + "deliveryStart": "2025-10-01T11:30:00Z", + "deliveryEnd": "2025-10-01T11:45:00Z", "entryPerArea": { - "SE3": 63.49 + "SE3": 679.14 } }, { - "deliveryStart": "2025-07-06T11:45:00Z", - "deliveryEnd": "2025-07-06T12:00:00Z", + "deliveryStart": "2025-10-01T11:45:00Z", + "deliveryEnd": "2025-10-01T12:00:00Z", "entryPerArea": { - "SE3": 63.49 + "SE3": 694.61 } }, { - "deliveryStart": "2025-07-06T12:00:00Z", - "deliveryEnd": "2025-07-06T12:15:00Z", + "deliveryStart": "2025-10-01T12:00:00Z", + "deliveryEnd": "2025-10-01T12:15:00Z", "entryPerArea": { - "SE3": 91.64 + "SE3": 622.33 } }, { - "deliveryStart": "2025-07-06T12:15:00Z", - "deliveryEnd": "2025-07-06T12:30:00Z", + "deliveryStart": "2025-10-01T12:15:00Z", + "deliveryEnd": "2025-10-01T12:30:00Z", "entryPerArea": { - "SE3": 91.64 + "SE3": 685.44 } }, { - "deliveryStart": "2025-07-06T12:30:00Z", - "deliveryEnd": "2025-07-06T12:45:00Z", + "deliveryStart": "2025-10-01T12:30:00Z", + "deliveryEnd": "2025-10-01T12:45:00Z", "entryPerArea": { - "SE3": 91.64 + "SE3": 732.85 } }, { - "deliveryStart": "2025-07-06T12:45:00Z", - "deliveryEnd": "2025-07-06T13:00:00Z", + "deliveryStart": "2025-10-01T12:45:00Z", + "deliveryEnd": "2025-10-01T13:00:00Z", "entryPerArea": { - "SE3": 91.64 + "SE3": 801.92 } }, { - "deliveryStart": "2025-07-06T13:00:00Z", - "deliveryEnd": "2025-07-06T13:15:00Z", + "deliveryStart": "2025-10-01T13:00:00Z", + "deliveryEnd": "2025-10-01T13:15:00Z", "entryPerArea": { - "SE3": 111.79 + "SE3": 629.4 } }, { - "deliveryStart": "2025-07-06T13:15:00Z", - "deliveryEnd": "2025-07-06T13:30:00Z", + "deliveryStart": "2025-10-01T13:15:00Z", + "deliveryEnd": "2025-10-01T13:30:00Z", "entryPerArea": { - "SE3": 111.79 + "SE3": 729.53 } }, { - "deliveryStart": "2025-07-06T13:30:00Z", - "deliveryEnd": "2025-07-06T13:45:00Z", + "deliveryStart": "2025-10-01T13:30:00Z", + "deliveryEnd": "2025-10-01T13:45:00Z", "entryPerArea": { - "SE3": 111.79 + "SE3": 884.81 } }, { - "deliveryStart": "2025-07-06T13:45:00Z", - "deliveryEnd": "2025-07-06T14:00:00Z", + "deliveryStart": "2025-10-01T13:45:00Z", + "deliveryEnd": "2025-10-01T14:00:00Z", "entryPerArea": { - "SE3": 111.79 + "SE3": 984.94 } }, { - "deliveryStart": "2025-07-06T14:00:00Z", - "deliveryEnd": "2025-07-06T14:15:00Z", + "deliveryStart": "2025-10-01T14:00:00Z", + "deliveryEnd": "2025-10-01T14:15:00Z", "entryPerArea": { - "SE3": 234.04 + "SE3": 615.26 } }, { - "deliveryStart": "2025-07-06T14:15:00Z", - "deliveryEnd": "2025-07-06T14:30:00Z", + "deliveryStart": "2025-10-01T14:15:00Z", + "deliveryEnd": "2025-10-01T14:30:00Z", "entryPerArea": { - "SE3": 234.04 + "SE3": 902.94 } }, { - "deliveryStart": "2025-07-06T14:30:00Z", - "deliveryEnd": "2025-07-06T14:45:00Z", + "deliveryStart": "2025-10-01T14:30:00Z", + "deliveryEnd": "2025-10-01T14:45:00Z", "entryPerArea": { - "SE3": 234.04 + "SE3": 1043.85 } }, { - "deliveryStart": "2025-07-06T14:45:00Z", - "deliveryEnd": "2025-07-06T15:00:00Z", + "deliveryStart": "2025-10-01T14:45:00Z", + "deliveryEnd": "2025-10-01T15:00:00Z", "entryPerArea": { - "SE3": 234.04 + "SE3": 1075.12 } }, { - "deliveryStart": "2025-07-06T15:00:00Z", - "deliveryEnd": "2025-07-06T15:15:00Z", + "deliveryStart": "2025-10-01T15:00:00Z", + "deliveryEnd": "2025-10-01T15:15:00Z", "entryPerArea": { - "SE3": 435.33 + "SE3": 980.52 } }, { - "deliveryStart": "2025-07-06T15:15:00Z", - "deliveryEnd": "2025-07-06T15:30:00Z", + "deliveryStart": "2025-10-01T15:15:00Z", + "deliveryEnd": "2025-10-01T15:30:00Z", "entryPerArea": { - "SE3": 435.33 + "SE3": 1162.66 } }, { - "deliveryStart": "2025-07-06T15:30:00Z", - "deliveryEnd": "2025-07-06T15:45:00Z", + "deliveryStart": "2025-10-01T15:30:00Z", + "deliveryEnd": "2025-10-01T15:45:00Z", "entryPerArea": { - "SE3": 435.33 + "SE3": 1453.87 } }, { - "deliveryStart": "2025-07-06T15:45:00Z", - "deliveryEnd": "2025-07-06T16:00:00Z", + "deliveryStart": "2025-10-01T15:45:00Z", + "deliveryEnd": "2025-10-01T16:00:00Z", "entryPerArea": { - "SE3": 435.33 + "SE3": 1955.96 } }, { - "deliveryStart": "2025-07-06T16:00:00Z", - "deliveryEnd": "2025-07-06T16:15:00Z", + "deliveryStart": "2025-10-01T16:00:00Z", + "deliveryEnd": "2025-10-01T16:15:00Z", "entryPerArea": { - "SE3": 431.84 + "SE3": 1423.48 } }, { - "deliveryStart": "2025-07-06T16:15:00Z", - "deliveryEnd": "2025-07-06T16:30:00Z", + "deliveryStart": "2025-10-01T16:15:00Z", + "deliveryEnd": "2025-10-01T16:30:00Z", "entryPerArea": { - "SE3": 431.84 + "SE3": 1900.04 } }, { - "deliveryStart": "2025-07-06T16:30:00Z", - "deliveryEnd": "2025-07-06T16:45:00Z", + "deliveryStart": "2025-10-01T16:30:00Z", + "deliveryEnd": "2025-10-01T16:45:00Z", "entryPerArea": { - "SE3": 431.84 + "SE3": 2611.11 } }, { - "deliveryStart": "2025-07-06T16:45:00Z", - "deliveryEnd": "2025-07-06T17:00:00Z", + "deliveryStart": "2025-10-01T16:45:00Z", + "deliveryEnd": "2025-10-01T17:00:00Z", "entryPerArea": { - "SE3": 431.84 + "SE3": 3467.41 } }, { - "deliveryStart": "2025-07-06T17:00:00Z", - "deliveryEnd": "2025-07-06T17:15:00Z", + "deliveryStart": "2025-10-01T17:00:00Z", + "deliveryEnd": "2025-10-01T17:15:00Z", "entryPerArea": { - "SE3": 423.73 + "SE3": 3828.03 } }, { - "deliveryStart": "2025-07-06T17:15:00Z", - "deliveryEnd": "2025-07-06T17:30:00Z", + "deliveryStart": "2025-10-01T17:15:00Z", + "deliveryEnd": "2025-10-01T17:30:00Z", "entryPerArea": { - "SE3": 423.73 + "SE3": 3429.83 } }, { - "deliveryStart": "2025-07-06T17:30:00Z", - "deliveryEnd": "2025-07-06T17:45:00Z", + "deliveryStart": "2025-10-01T17:30:00Z", + "deliveryEnd": "2025-10-01T17:45:00Z", "entryPerArea": { - "SE3": 423.73 + "SE3": 2934.38 } }, { - "deliveryStart": "2025-07-06T17:45:00Z", - "deliveryEnd": "2025-07-06T18:00:00Z", + "deliveryStart": "2025-10-01T17:45:00Z", + "deliveryEnd": "2025-10-01T18:00:00Z", "entryPerArea": { - "SE3": 423.73 + "SE3": 2308.07 } }, { - "deliveryStart": "2025-07-06T18:00:00Z", - "deliveryEnd": "2025-07-06T18:15:00Z", + "deliveryStart": "2025-10-01T18:00:00Z", + "deliveryEnd": "2025-10-01T18:15:00Z", "entryPerArea": { - "SE3": 437.92 + "SE3": 1997.96 } }, { - "deliveryStart": "2025-07-06T18:15:00Z", - "deliveryEnd": "2025-07-06T18:30:00Z", + "deliveryStart": "2025-10-01T18:15:00Z", + "deliveryEnd": "2025-10-01T18:30:00Z", "entryPerArea": { - "SE3": 437.92 + "SE3": 1424.03 } }, { - "deliveryStart": "2025-07-06T18:30:00Z", - "deliveryEnd": "2025-07-06T18:45:00Z", + "deliveryStart": "2025-10-01T18:30:00Z", + "deliveryEnd": "2025-10-01T18:45:00Z", "entryPerArea": { - "SE3": 437.92 + "SE3": 1216.81 } }, { - "deliveryStart": "2025-07-06T18:45:00Z", - "deliveryEnd": "2025-07-06T19:00:00Z", + "deliveryStart": "2025-10-01T18:45:00Z", + "deliveryEnd": "2025-10-01T19:00:00Z", "entryPerArea": { - "SE3": 437.92 + "SE3": 1070.15 } }, { - "deliveryStart": "2025-07-06T19:00:00Z", - "deliveryEnd": "2025-07-06T19:15:00Z", + "deliveryStart": "2025-10-01T19:00:00Z", + "deliveryEnd": "2025-10-01T19:15:00Z", "entryPerArea": { - "SE3": 416.42 + "SE3": 1218.14 } }, { - "deliveryStart": "2025-07-06T19:15:00Z", - "deliveryEnd": "2025-07-06T19:30:00Z", + "deliveryStart": "2025-10-01T19:15:00Z", + "deliveryEnd": "2025-10-01T19:30:00Z", "entryPerArea": { - "SE3": 416.42 + "SE3": 1135.8 } }, { - "deliveryStart": "2025-07-06T19:30:00Z", - "deliveryEnd": "2025-07-06T19:45:00Z", + "deliveryStart": "2025-10-01T19:30:00Z", + "deliveryEnd": "2025-10-01T19:45:00Z", "entryPerArea": { - "SE3": 416.42 + "SE3": 959.96 } }, { - "deliveryStart": "2025-07-06T19:45:00Z", - "deliveryEnd": "2025-07-06T20:00:00Z", + "deliveryStart": "2025-10-01T19:45:00Z", + "deliveryEnd": "2025-10-01T20:00:00Z", "entryPerArea": { - "SE3": 416.42 + "SE3": 913.66 } }, { - "deliveryStart": "2025-07-06T20:00:00Z", - "deliveryEnd": "2025-07-06T20:15:00Z", + "deliveryStart": "2025-10-01T20:00:00Z", + "deliveryEnd": "2025-10-01T20:15:00Z", "entryPerArea": { - "SE3": 414.39 + "SE3": 1001.63 } }, { - "deliveryStart": "2025-07-06T20:15:00Z", - "deliveryEnd": "2025-07-06T20:30:00Z", + "deliveryStart": "2025-10-01T20:15:00Z", + "deliveryEnd": "2025-10-01T20:30:00Z", "entryPerArea": { - "SE3": 414.39 + "SE3": 933.0 } }, { - "deliveryStart": "2025-07-06T20:30:00Z", - "deliveryEnd": "2025-07-06T20:45:00Z", + "deliveryStart": "2025-10-01T20:30:00Z", + "deliveryEnd": "2025-10-01T20:45:00Z", "entryPerArea": { - "SE3": 414.39 + "SE3": 874.53 } }, { - "deliveryStart": "2025-07-06T20:45:00Z", - "deliveryEnd": "2025-07-06T21:00:00Z", + "deliveryStart": "2025-10-01T20:45:00Z", + "deliveryEnd": "2025-10-01T21:00:00Z", "entryPerArea": { - "SE3": 414.39 + "SE3": 821.71 } }, { - "deliveryStart": "2025-07-06T21:00:00Z", - "deliveryEnd": "2025-07-06T21:15:00Z", + "deliveryStart": "2025-10-01T21:00:00Z", + "deliveryEnd": "2025-10-01T21:15:00Z", "entryPerArea": { - "SE3": 396.38 + "SE3": 860.5 } }, { - "deliveryStart": "2025-07-06T21:15:00Z", - "deliveryEnd": "2025-07-06T21:30:00Z", + "deliveryStart": "2025-10-01T21:15:00Z", + "deliveryEnd": "2025-10-01T21:30:00Z", "entryPerArea": { - "SE3": 396.38 + "SE3": 840.16 } }, { - "deliveryStart": "2025-07-06T21:30:00Z", - "deliveryEnd": "2025-07-06T21:45:00Z", + "deliveryStart": "2025-10-01T21:30:00Z", + "deliveryEnd": "2025-10-01T21:45:00Z", "entryPerArea": { - "SE3": 396.38 + "SE3": 820.05 } }, { - "deliveryStart": "2025-07-06T21:45:00Z", - "deliveryEnd": "2025-07-06T22:00:00Z", + "deliveryStart": "2025-10-01T21:45:00Z", + "deliveryEnd": "2025-10-01T22:00:00Z", "entryPerArea": { - "SE3": 396.38 + "SE3": 785.68 } } ] diff --git a/tests/components/nordpool/fixtures/indices_60.json b/tests/components/nordpool/fixtures/indices_60.json index 97bbe554b13..d9df6671d89 100644 --- a/tests/components/nordpool/fixtures/indices_60.json +++ b/tests/components/nordpool/fixtures/indices_60.json @@ -1,184 +1,184 @@ { - "deliveryDateCET": "2025-07-06", - "version": 2, - "updatedAt": "2025-07-05T10:56:44.6936838Z", + "deliveryDateCET": "2025-10-01", + "version": 3, + "updatedAt": "2025-09-30T12:08:22.6180024Z", "market": "DayAhead", "indexNames": ["SE3"], "currency": "SEK", "resolutionInMinutes": 60, "areaStates": [ { - "state": "Preliminary", + "state": "Final", "areas": ["SE3"] } ], "multiIndexEntries": [ { - "deliveryStart": "2025-07-05T22:00:00Z", - "deliveryEnd": "2025-07-05T23:00:00Z", + "deliveryStart": "2025-09-30T22:00:00Z", + "deliveryEnd": "2025-09-30T23:00:00Z", "entryPerArea": { - "SE3": 43.57 + "SE3": 523.75 } }, { - "deliveryStart": "2025-07-05T23:00:00Z", - "deliveryEnd": "2025-07-06T00:00:00Z", + "deliveryStart": "2025-09-30T23:00:00Z", + "deliveryEnd": "2025-10-01T00:00:00Z", "entryPerArea": { - "SE3": 36.47 + "SE3": 485.95 } }, { - "deliveryStart": "2025-07-06T00:00:00Z", - "deliveryEnd": "2025-07-06T01:00:00Z", + "deliveryStart": "2025-10-01T00:00:00Z", + "deliveryEnd": "2025-10-01T01:00:00Z", "entryPerArea": { - "SE3": 35.57 + "SE3": 504.85 } }, { - "deliveryStart": "2025-07-06T01:00:00Z", - "deliveryEnd": "2025-07-06T02:00:00Z", + "deliveryStart": "2025-10-01T01:00:00Z", + "deliveryEnd": "2025-10-01T02:00:00Z", "entryPerArea": { - "SE3": 30.73 + "SE3": 442.07 } }, { - "deliveryStart": "2025-07-06T02:00:00Z", - "deliveryEnd": "2025-07-06T03:00:00Z", + "deliveryStart": "2025-10-01T02:00:00Z", + "deliveryEnd": "2025-10-01T03:00:00Z", "entryPerArea": { - "SE3": 32.42 + "SE3": 496.12 } }, { - "deliveryStart": "2025-07-06T03:00:00Z", - "deliveryEnd": "2025-07-06T04:00:00Z", + "deliveryStart": "2025-10-01T03:00:00Z", + "deliveryEnd": "2025-10-01T04:00:00Z", "entryPerArea": { - "SE3": 38.73 + "SE3": 670.85 } }, { - "deliveryStart": "2025-07-06T04:00:00Z", - "deliveryEnd": "2025-07-06T05:00:00Z", + "deliveryStart": "2025-10-01T04:00:00Z", + "deliveryEnd": "2025-10-01T05:00:00Z", "entryPerArea": { - "SE3": 42.78 + "SE3": 1149.72 } }, { - "deliveryStart": "2025-07-06T05:00:00Z", - "deliveryEnd": "2025-07-06T06:00:00Z", + "deliveryStart": "2025-10-01T05:00:00Z", + "deliveryEnd": "2025-10-01T06:00:00Z", "entryPerArea": { - "SE3": 54.71 + "SE3": 1694.25 } }, { - "deliveryStart": "2025-07-06T06:00:00Z", - "deliveryEnd": "2025-07-06T07:00:00Z", + "deliveryStart": "2025-10-01T06:00:00Z", + "deliveryEnd": "2025-10-01T07:00:00Z", "entryPerArea": { - "SE3": 83.87 + "SE3": 1378.06 } }, { - "deliveryStart": "2025-07-06T07:00:00Z", - "deliveryEnd": "2025-07-06T08:00:00Z", + "deliveryStart": "2025-10-01T07:00:00Z", + "deliveryEnd": "2025-10-01T08:00:00Z", "entryPerArea": { - "SE3": 78.8 + "SE3": 1063.41 } }, { - "deliveryStart": "2025-07-06T08:00:00Z", - "deliveryEnd": "2025-07-06T09:00:00Z", + "deliveryStart": "2025-10-01T08:00:00Z", + "deliveryEnd": "2025-10-01T09:00:00Z", "entryPerArea": { - "SE3": 92.09 + "SE3": 874.53 } }, { - "deliveryStart": "2025-07-06T09:00:00Z", - "deliveryEnd": "2025-07-06T10:00:00Z", + "deliveryStart": "2025-10-01T09:00:00Z", + "deliveryEnd": "2025-10-01T10:00:00Z", "entryPerArea": { - "SE3": 104.92 + "SE3": 708.2 } }, { - "deliveryStart": "2025-07-06T10:00:00Z", - "deliveryEnd": "2025-07-06T11:00:00Z", + "deliveryStart": "2025-10-01T10:00:00Z", + "deliveryEnd": "2025-10-01T11:00:00Z", "entryPerArea": { - "SE3": 72.5 + "SE3": 646.53 } }, { - "deliveryStart": "2025-07-06T11:00:00Z", - "deliveryEnd": "2025-07-06T12:00:00Z", + "deliveryStart": "2025-10-01T11:00:00Z", + "deliveryEnd": "2025-10-01T12:00:00Z", "entryPerArea": { - "SE3": 63.49 + "SE3": 667.97 } }, { - "deliveryStart": "2025-07-06T12:00:00Z", - "deliveryEnd": "2025-07-06T13:00:00Z", + "deliveryStart": "2025-10-01T12:00:00Z", + "deliveryEnd": "2025-10-01T13:00:00Z", "entryPerArea": { - "SE3": 91.64 + "SE3": 710.63 } }, { - "deliveryStart": "2025-07-06T13:00:00Z", - "deliveryEnd": "2025-07-06T14:00:00Z", + "deliveryStart": "2025-10-01T13:00:00Z", + "deliveryEnd": "2025-10-01T14:00:00Z", "entryPerArea": { - "SE3": 111.79 + "SE3": 807.23 } }, { - "deliveryStart": "2025-07-06T14:00:00Z", - "deliveryEnd": "2025-07-06T15:00:00Z", + "deliveryStart": "2025-10-01T14:00:00Z", + "deliveryEnd": "2025-10-01T15:00:00Z", "entryPerArea": { - "SE3": 234.04 + "SE3": 909.35 } }, { - "deliveryStart": "2025-07-06T15:00:00Z", - "deliveryEnd": "2025-07-06T16:00:00Z", + "deliveryStart": "2025-10-01T15:00:00Z", + "deliveryEnd": "2025-10-01T16:00:00Z", "entryPerArea": { - "SE3": 435.33 + "SE3": 1388.22 } }, { - "deliveryStart": "2025-07-06T16:00:00Z", - "deliveryEnd": "2025-07-06T17:00:00Z", + "deliveryStart": "2025-10-01T16:00:00Z", + "deliveryEnd": "2025-10-01T17:00:00Z", "entryPerArea": { - "SE3": 431.84 + "SE3": 2350.51 } }, { - "deliveryStart": "2025-07-06T17:00:00Z", - "deliveryEnd": "2025-07-06T18:00:00Z", + "deliveryStart": "2025-10-01T17:00:00Z", + "deliveryEnd": "2025-10-01T18:00:00Z", "entryPerArea": { - "SE3": 423.73 + "SE3": 3125.13 } }, { - "deliveryStart": "2025-07-06T18:00:00Z", - "deliveryEnd": "2025-07-06T19:00:00Z", + "deliveryStart": "2025-10-01T18:00:00Z", + "deliveryEnd": "2025-10-01T19:00:00Z", "entryPerArea": { - "SE3": 437.92 + "SE3": 1427.24 } }, { - "deliveryStart": "2025-07-06T19:00:00Z", - "deliveryEnd": "2025-07-06T20:00:00Z", + "deliveryStart": "2025-10-01T19:00:00Z", + "deliveryEnd": "2025-10-01T20:00:00Z", "entryPerArea": { - "SE3": 416.42 + "SE3": 1056.89 } }, { - "deliveryStart": "2025-07-06T20:00:00Z", - "deliveryEnd": "2025-07-06T21:00:00Z", + "deliveryStart": "2025-10-01T20:00:00Z", + "deliveryEnd": "2025-10-01T21:00:00Z", "entryPerArea": { - "SE3": 414.39 + "SE3": 907.69 } }, { - "deliveryStart": "2025-07-06T21:00:00Z", - "deliveryEnd": "2025-07-06T22:00:00Z", + "deliveryStart": "2025-10-01T21:00:00Z", + "deliveryEnd": "2025-10-01T22:00:00Z", "entryPerArea": { - "SE3": 396.38 + "SE3": 826.57 } } ] diff --git a/tests/components/nordpool/snapshots/test_diagnostics.ambr b/tests/components/nordpool/snapshots/test_diagnostics.ambr index d7f7c4041cd..a4434a1246a 100644 --- a/tests/components/nordpool/snapshots/test_diagnostics.ambr +++ b/tests/components/nordpool/snapshots/test_diagnostics.ambr @@ -2,15 +2,15 @@ # name: test_diagnostics dict({ 'raw': dict({ - '2024-11-04': dict({ + '2025-09-30': dict({ 'areaAverages': list([ dict({ 'areaCode': 'SE3', - 'price': 1006.88, + 'price': 974.99, }), dict({ 'areaCode': 'SE4', - 'price': 1190.46, + 'price': 1135.54, }), ]), 'areaStates': list([ @@ -26,53 +26,53 @@ dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 408.69, - 'max': 1426.17, - 'min': 66.13, + 'average': 594.26, + 'max': 1909.0, + 'min': 242.43, }), 'SE4': dict({ - 'average': 530.61, - 'max': 1695.95, - 'min': 78.59, + 'average': 713.53, + 'max': 2247.98, + 'min': 311.51, }), }), 'blockName': 'Off-peak 1', - 'deliveryEnd': '2024-11-04T07:00:00Z', - 'deliveryStart': '2024-11-03T23:00:00Z', + 'deliveryEnd': '2025-09-30T06:00:00Z', + 'deliveryStart': '2025-09-29T22:00:00Z', }), dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 1455.48, - 'max': 2812.53, - 'min': 952.76, + 'average': 1249.39, + 'max': 2652.18, + 'min': 821.53, }), 'SE4': dict({ - 'average': 1692.2, - 'max': 3313.53, - 'min': 1085.73, + 'average': 1439.73, + 'max': 3083.2, + 'min': 927.24, }), }), 'blockName': 'Peak', - 'deliveryEnd': '2024-11-04T19:00:00Z', - 'deliveryStart': '2024-11-04T07:00:00Z', + 'deliveryEnd': '2025-09-30T18:00:00Z', + 'deliveryStart': '2025-09-30T06:00:00Z', }), dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 857.49, - 'max': 1165.02, - 'min': 465.38, + 'average': 913.23, + 'max': 1109.76, + 'min': 697.17, }), 'SE4': dict({ - 'average': 1004.95, - 'max': 1398.35, - 'min': 528.83, + 'average': 1067.0, + 'max': 1305.73, + 'min': 812.37, }), }), 'blockName': 'Off-peak 2', - 'deliveryEnd': '2024-11-04T23:00:00Z', - 'deliveryStart': '2024-11-04T19:00:00Z', + 'deliveryEnd': '2025-09-30T22:00:00Z', + 'deliveryStart': '2025-09-30T18:00:00Z', }), ]), 'currency': 'SEK', @@ -80,215 +80,215 @@ 'SE3', 'SE4', ]), - 'deliveryDateCET': '2024-11-04', - 'exchangeRate': 11.64318, + 'deliveryDateCET': '2025-09-30', + 'exchangeRate': 11.03467, 'market': 'DayAhead', 'multiAreaEntries': list([ dict({ - 'deliveryEnd': '2024-11-04T00:00:00Z', - 'deliveryStart': '2024-11-03T23:00:00Z', + 'deliveryEnd': '2025-09-29T23:00:00Z', + 'deliveryStart': '2025-09-29T22:00:00Z', 'entryPerArea': dict({ - 'SE3': 66.13, - 'SE4': 78.59, + 'SE3': 278.63, + 'SE4': 354.65, }), }), dict({ - 'deliveryEnd': '2024-11-04T01:00:00Z', - 'deliveryStart': '2024-11-04T00:00:00Z', + 'deliveryEnd': '2025-09-30T00:00:00Z', + 'deliveryStart': '2025-09-29T23:00:00Z', 'entryPerArea': dict({ - 'SE3': 72.54, - 'SE4': 86.51, + 'SE3': 261.85, + 'SE4': 336.89, }), }), dict({ - 'deliveryEnd': '2024-11-04T02:00:00Z', - 'deliveryStart': '2024-11-04T01:00:00Z', + 'deliveryEnd': '2025-09-30T01:00:00Z', + 'deliveryStart': '2025-09-30T00:00:00Z', 'entryPerArea': dict({ - 'SE3': 73.12, - 'SE4': 84.88, + 'SE3': 242.43, + 'SE4': 313.16, }), }), dict({ - 'deliveryEnd': '2024-11-04T03:00:00Z', - 'deliveryStart': '2024-11-04T02:00:00Z', + 'deliveryEnd': '2025-09-30T02:00:00Z', + 'deliveryStart': '2025-09-30T01:00:00Z', 'entryPerArea': dict({ - 'SE3': 171.97, - 'SE4': 217.26, + 'SE3': 322.65, + 'SE4': 401.0, }), }), dict({ - 'deliveryEnd': '2024-11-04T04:00:00Z', - 'deliveryStart': '2024-11-04T03:00:00Z', + 'deliveryEnd': '2025-09-30T03:00:00Z', + 'deliveryStart': '2025-09-30T02:00:00Z', 'entryPerArea': dict({ - 'SE3': 181.05, - 'SE4': 227.74, + 'SE3': 243.2, + 'SE4': 311.51, }), }), dict({ - 'deliveryEnd': '2024-11-04T05:00:00Z', - 'deliveryStart': '2024-11-04T04:00:00Z', + 'deliveryEnd': '2025-09-30T04:00:00Z', + 'deliveryStart': '2025-09-30T03:00:00Z', 'entryPerArea': dict({ - 'SE3': 360.71, - 'SE4': 414.61, + 'SE3': 596.53, + 'SE4': 695.52, }), }), dict({ - 'deliveryEnd': '2024-11-04T06:00:00Z', - 'deliveryStart': '2024-11-04T05:00:00Z', + 'deliveryEnd': '2025-09-30T05:00:00Z', + 'deliveryStart': '2025-09-30T04:00:00Z', 'entryPerArea': dict({ - 'SE3': 917.83, - 'SE4': 1439.33, + 'SE3': 899.77, + 'SE4': 1047.52, }), }), dict({ - 'deliveryEnd': '2024-11-04T07:00:00Z', - 'deliveryStart': '2024-11-04T06:00:00Z', + 'deliveryEnd': '2025-09-30T06:00:00Z', + 'deliveryStart': '2025-09-30T05:00:00Z', 'entryPerArea': dict({ - 'SE3': 1426.17, - 'SE4': 1695.95, + 'SE3': 1909.0, + 'SE4': 2247.98, }), }), dict({ - 'deliveryEnd': '2024-11-04T08:00:00Z', - 'deliveryStart': '2024-11-04T07:00:00Z', + 'deliveryEnd': '2025-09-30T07:00:00Z', + 'deliveryStart': '2025-09-30T06:00:00Z', 'entryPerArea': dict({ - 'SE3': 1350.96, - 'SE4': 1605.13, + 'SE3': 1432.52, + 'SE4': 1681.24, }), }), dict({ - 'deliveryEnd': '2024-11-04T09:00:00Z', - 'deliveryStart': '2024-11-04T08:00:00Z', + 'deliveryEnd': '2025-09-30T08:00:00Z', + 'deliveryStart': '2025-09-30T07:00:00Z', 'entryPerArea': dict({ - 'SE3': 1195.06, - 'SE4': 1393.46, + 'SE3': 1127.52, + 'SE4': 1304.96, }), }), dict({ - 'deliveryEnd': '2024-11-04T10:00:00Z', - 'deliveryStart': '2024-11-04T09:00:00Z', + 'deliveryEnd': '2025-09-30T09:00:00Z', + 'deliveryStart': '2025-09-30T08:00:00Z', 'entryPerArea': dict({ - 'SE3': 992.35, - 'SE4': 1126.71, + 'SE3': 966.75, + 'SE4': 1073.34, }), }), dict({ - 'deliveryEnd': '2024-11-04T11:00:00Z', - 'deliveryStart': '2024-11-04T10:00:00Z', + 'deliveryEnd': '2025-09-30T10:00:00Z', + 'deliveryStart': '2025-09-30T09:00:00Z', 'entryPerArea': dict({ - 'SE3': 976.63, - 'SE4': 1107.97, + 'SE3': 882.55, + 'SE4': 1003.93, }), }), dict({ - 'deliveryEnd': '2024-11-04T12:00:00Z', - 'deliveryStart': '2024-11-04T11:00:00Z', + 'deliveryEnd': '2025-09-30T11:00:00Z', + 'deliveryStart': '2025-09-30T10:00:00Z', 'entryPerArea': dict({ - 'SE3': 952.76, - 'SE4': 1085.73, + 'SE3': 841.72, + 'SE4': 947.44, }), }), dict({ - 'deliveryEnd': '2024-11-04T13:00:00Z', - 'deliveryStart': '2024-11-04T12:00:00Z', + 'deliveryEnd': '2025-09-30T12:00:00Z', + 'deliveryStart': '2025-09-30T11:00:00Z', 'entryPerArea': dict({ - 'SE3': 1029.37, - 'SE4': 1177.71, + 'SE3': 821.53, + 'SE4': 927.24, }), }), dict({ - 'deliveryEnd': '2024-11-04T14:00:00Z', - 'deliveryStart': '2024-11-04T13:00:00Z', + 'deliveryEnd': '2025-09-30T13:00:00Z', + 'deliveryStart': '2025-09-30T12:00:00Z', 'entryPerArea': dict({ - 'SE3': 1043.35, - 'SE4': 1194.59, + 'SE3': 864.35, + 'SE4': 970.5, }), }), dict({ - 'deliveryEnd': '2024-11-04T15:00:00Z', - 'deliveryStart': '2024-11-04T14:00:00Z', + 'deliveryEnd': '2025-09-30T14:00:00Z', + 'deliveryStart': '2025-09-30T13:00:00Z', 'entryPerArea': dict({ - 'SE3': 1359.57, - 'SE4': 1561.12, + 'SE3': 931.88, + 'SE4': 1046.64, }), }), dict({ - 'deliveryEnd': '2024-11-04T16:00:00Z', - 'deliveryStart': '2024-11-04T15:00:00Z', + 'deliveryEnd': '2025-09-30T15:00:00Z', + 'deliveryStart': '2025-09-30T14:00:00Z', 'entryPerArea': dict({ - 'SE3': 1848.35, - 'SE4': 2145.84, + 'SE3': 1039.13, + 'SE4': 1165.04, }), }), dict({ - 'deliveryEnd': '2024-11-04T17:00:00Z', - 'deliveryStart': '2024-11-04T16:00:00Z', + 'deliveryEnd': '2025-09-30T16:00:00Z', + 'deliveryStart': '2025-09-30T15:00:00Z', 'entryPerArea': dict({ - 'SE3': 2812.53, - 'SE4': 3313.53, + 'SE3': 1296.57, + 'SE4': 1520.91, }), }), dict({ - 'deliveryEnd': '2024-11-04T18:00:00Z', - 'deliveryStart': '2024-11-04T17:00:00Z', + 'deliveryEnd': '2025-09-30T17:00:00Z', + 'deliveryStart': '2025-09-30T16:00:00Z', 'entryPerArea': dict({ - 'SE3': 2351.69, - 'SE4': 2751.87, + 'SE3': 2652.18, + 'SE4': 3083.2, }), }), dict({ - 'deliveryEnd': '2024-11-04T19:00:00Z', - 'deliveryStart': '2024-11-04T18:00:00Z', + 'deliveryEnd': '2025-09-30T18:00:00Z', + 'deliveryStart': '2025-09-30T17:00:00Z', 'entryPerArea': dict({ - 'SE3': 1553.08, - 'SE4': 1842.77, + 'SE3': 2135.98, + 'SE4': 2552.32, }), }), dict({ - 'deliveryEnd': '2024-11-04T20:00:00Z', - 'deliveryStart': '2024-11-04T19:00:00Z', + 'deliveryEnd': '2025-09-30T19:00:00Z', + 'deliveryStart': '2025-09-30T18:00:00Z', 'entryPerArea': dict({ - 'SE3': 1165.02, - 'SE4': 1398.35, + 'SE3': 1109.76, + 'SE4': 1305.73, }), }), dict({ - 'deliveryEnd': '2024-11-04T21:00:00Z', - 'deliveryStart': '2024-11-04T20:00:00Z', + 'deliveryEnd': '2025-09-30T20:00:00Z', + 'deliveryStart': '2025-09-30T19:00:00Z', 'entryPerArea': dict({ - 'SE3': 1007.48, - 'SE4': 1172.35, + 'SE3': 973.81, + 'SE4': 1130.83, }), }), dict({ - 'deliveryEnd': '2024-11-04T22:00:00Z', - 'deliveryStart': '2024-11-04T21:00:00Z', + 'deliveryEnd': '2025-09-30T21:00:00Z', + 'deliveryStart': '2025-09-30T20:00:00Z', 'entryPerArea': dict({ - 'SE3': 792.09, - 'SE4': 920.28, + 'SE3': 872.18, + 'SE4': 1019.05, }), }), dict({ - 'deliveryEnd': '2024-11-04T23:00:00Z', - 'deliveryStart': '2024-11-04T22:00:00Z', + 'deliveryEnd': '2025-09-30T22:00:00Z', + 'deliveryStart': '2025-09-30T21:00:00Z', 'entryPerArea': dict({ - 'SE3': 465.38, - 'SE4': 528.83, + 'SE3': 697.17, + 'SE4': 812.37, }), }), ]), - 'updatedAt': '2024-11-04T08:09:11.1931991Z', + 'updatedAt': '2025-09-29T11:17:12.3019385Z', 'version': 3, }), - '2024-11-05': dict({ + '2025-10-01': dict({ 'areaAverages': list([ dict({ 'areaCode': 'SE3', - 'price': 900.74, + 'price': 1033.98, }), dict({ 'areaCode': 'SE4', - 'price': 1166.12, + 'price': 1180.78, }), ]), 'areaStates': list([ @@ -304,53 +304,53 @@ dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 422.87, - 'max': 1406.14, - 'min': 61.69, + 'average': 745.93, + 'max': 1809.96, + 'min': 441.96, }), 'SE4': dict({ - 'average': 497.97, - 'max': 1648.25, - 'min': 65.19, + 'average': 860.99, + 'max': 2029.34, + 'min': 515.46, }), }), 'blockName': 'Off-peak 1', - 'deliveryEnd': '2024-11-05T07:00:00Z', - 'deliveryStart': '2024-11-04T23:00:00Z', + 'deliveryEnd': '2025-10-01T06:00:00Z', + 'deliveryStart': '2025-09-30T22:00:00Z', }), dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 1315.97, - 'max': 2512.65, - 'min': 925.05, + 'average': 1219.13, + 'max': 3828.03, + 'min': 607.74, }), 'SE4': dict({ - 'average': 1735.59, - 'max': 3533.03, - 'min': 1081.72, + 'average': 1381.22, + 'max': 4442.74, + 'min': 683.12, }), }), 'blockName': 'Peak', - 'deliveryEnd': '2024-11-05T19:00:00Z', - 'deliveryStart': '2024-11-05T07:00:00Z', + 'deliveryEnd': '2025-10-01T18:00:00Z', + 'deliveryStart': '2025-10-01T06:00:00Z', }), dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 610.79, - 'max': 835.53, - 'min': 289.14, + 'average': 1054.61, + 'max': 1997.96, + 'min': 785.68, }), 'SE4': dict({ - 'average': 793.98, - 'max': 1112.57, - 'min': 349.21, + 'average': 1219.07, + 'max': 2312.16, + 'min': 912.22, }), }), 'blockName': 'Off-peak 2', - 'deliveryEnd': '2024-11-05T23:00:00Z', - 'deliveryStart': '2024-11-05T19:00:00Z', + 'deliveryEnd': '2025-10-01T22:00:00Z', + 'deliveryStart': '2025-10-01T18:00:00Z', }), ]), 'currency': 'SEK', @@ -358,215 +358,791 @@ 'SE3', 'SE4', ]), - 'deliveryDateCET': '2024-11-05', - 'exchangeRate': 11.6402, + 'deliveryDateCET': '2025-10-01', + 'exchangeRate': 11.05186, 'market': 'DayAhead', 'multiAreaEntries': list([ dict({ - 'deliveryEnd': '2024-11-05T00:00:00Z', - 'deliveryStart': '2024-11-04T23:00:00Z', + 'deliveryEnd': '2025-09-30T22:15:00Z', + 'deliveryStart': '2025-09-30T22:00:00Z', 'entryPerArea': dict({ - 'SE3': 250.73, - 'SE4': 283.79, + 'SE3': 556.68, + 'SE4': 642.22, }), }), dict({ - 'deliveryEnd': '2024-11-05T01:00:00Z', - 'deliveryStart': '2024-11-05T00:00:00Z', + 'deliveryEnd': '2025-09-30T22:30:00Z', + 'deliveryStart': '2025-09-30T22:15:00Z', 'entryPerArea': dict({ - 'SE3': 76.36, - 'SE4': 81.36, + 'SE3': 519.88, + 'SE4': 600.12, }), }), dict({ - 'deliveryEnd': '2024-11-05T02:00:00Z', - 'deliveryStart': '2024-11-05T01:00:00Z', + 'deliveryEnd': '2025-09-30T22:45:00Z', + 'deliveryStart': '2025-09-30T22:30:00Z', 'entryPerArea': dict({ - 'SE3': 73.92, - 'SE4': 79.15, + 'SE3': 508.28, + 'SE4': 586.3, }), }), dict({ - 'deliveryEnd': '2024-11-05T03:00:00Z', - 'deliveryStart': '2024-11-05T02:00:00Z', + 'deliveryEnd': '2025-09-30T23:00:00Z', + 'deliveryStart': '2025-09-30T22:45:00Z', 'entryPerArea': dict({ - 'SE3': 61.69, - 'SE4': 65.19, + 'SE3': 509.93, + 'SE4': 589.62, }), }), dict({ - 'deliveryEnd': '2024-11-05T04:00:00Z', - 'deliveryStart': '2024-11-05T03:00:00Z', + 'deliveryEnd': '2025-09-30T23:15:00Z', + 'deliveryStart': '2025-09-30T23:00:00Z', 'entryPerArea': dict({ - 'SE3': 64.6, - 'SE4': 68.44, + 'SE3': 501.64, + 'SE4': 577.24, }), }), dict({ - 'deliveryEnd': '2024-11-05T05:00:00Z', - 'deliveryStart': '2024-11-05T04:00:00Z', + 'deliveryEnd': '2025-09-30T23:30:00Z', + 'deliveryStart': '2025-09-30T23:15:00Z', 'entryPerArea': dict({ - 'SE3': 453.27, - 'SE4': 516.71, + 'SE3': 509.05, + 'SE4': 585.42, }), }), dict({ - 'deliveryEnd': '2024-11-05T06:00:00Z', - 'deliveryStart': '2024-11-05T05:00:00Z', + 'deliveryEnd': '2025-09-30T23:45:00Z', + 'deliveryStart': '2025-09-30T23:30:00Z', 'entryPerArea': dict({ - 'SE3': 996.28, - 'SE4': 1240.85, + 'SE3': 491.03, + 'SE4': 567.18, }), }), dict({ - 'deliveryEnd': '2024-11-05T07:00:00Z', - 'deliveryStart': '2024-11-05T06:00:00Z', + 'deliveryEnd': '2025-10-01T00:00:00Z', + 'deliveryStart': '2025-09-30T23:45:00Z', 'entryPerArea': dict({ - 'SE3': 1406.14, - 'SE4': 1648.25, + 'SE3': 442.07, + 'SE4': 517.45, }), }), dict({ - 'deliveryEnd': '2024-11-05T08:00:00Z', - 'deliveryStart': '2024-11-05T07:00:00Z', + 'deliveryEnd': '2025-10-01T00:15:00Z', + 'deliveryStart': '2025-10-01T00:00:00Z', 'entryPerArea': dict({ - 'SE3': 1346.54, - 'SE4': 1570.5, + 'SE3': 504.08, + 'SE4': 580.55, }), }), dict({ - 'deliveryEnd': '2024-11-05T09:00:00Z', - 'deliveryStart': '2024-11-05T08:00:00Z', + 'deliveryEnd': '2025-10-01T00:30:00Z', + 'deliveryStart': '2025-10-01T00:15:00Z', 'entryPerArea': dict({ - 'SE3': 1150.28, - 'SE4': 1345.37, + 'SE3': 504.85, + 'SE4': 581.55, }), }), dict({ - 'deliveryEnd': '2024-11-05T10:00:00Z', - 'deliveryStart': '2024-11-05T09:00:00Z', + 'deliveryEnd': '2025-10-01T00:45:00Z', + 'deliveryStart': '2025-10-01T00:30:00Z', 'entryPerArea': dict({ - 'SE3': 1031.32, - 'SE4': 1206.51, + 'SE3': 504.3, + 'SE4': 580.78, }), }), dict({ - 'deliveryEnd': '2024-11-05T11:00:00Z', - 'deliveryStart': '2024-11-05T10:00:00Z', + 'deliveryEnd': '2025-10-01T01:00:00Z', + 'deliveryStart': '2025-10-01T00:45:00Z', 'entryPerArea': dict({ - 'SE3': 927.37, - 'SE4': 1085.8, + 'SE3': 506.29, + 'SE4': 583.1, }), }), dict({ - 'deliveryEnd': '2024-11-05T12:00:00Z', - 'deliveryStart': '2024-11-05T11:00:00Z', + 'deliveryEnd': '2025-10-01T01:15:00Z', + 'deliveryStart': '2025-10-01T01:00:00Z', 'entryPerArea': dict({ - 'SE3': 925.05, - 'SE4': 1081.72, + 'SE3': 442.07, + 'SE4': 515.46, }), }), dict({ - 'deliveryEnd': '2024-11-05T13:00:00Z', - 'deliveryStart': '2024-11-05T12:00:00Z', + 'deliveryEnd': '2025-10-01T01:30:00Z', + 'deliveryStart': '2025-10-01T01:15:00Z', 'entryPerArea': dict({ - 'SE3': 949.49, - 'SE4': 1130.38, + 'SE3': 441.96, + 'SE4': 517.23, }), }), dict({ - 'deliveryEnd': '2024-11-05T14:00:00Z', - 'deliveryStart': '2024-11-05T13:00:00Z', + 'deliveryEnd': '2025-10-01T01:45:00Z', + 'deliveryStart': '2025-10-01T01:30:00Z', 'entryPerArea': dict({ - 'SE3': 1042.03, - 'SE4': 1256.91, + 'SE3': 442.07, + 'SE4': 516.23, }), }), dict({ - 'deliveryEnd': '2024-11-05T15:00:00Z', - 'deliveryStart': '2024-11-05T14:00:00Z', + 'deliveryEnd': '2025-10-01T02:00:00Z', + 'deliveryStart': '2025-10-01T01:45:00Z', 'entryPerArea': dict({ - 'SE3': 1258.89, - 'SE4': 1765.82, + 'SE3': 442.07, + 'SE4': 516.23, }), }), dict({ - 'deliveryEnd': '2024-11-05T16:00:00Z', - 'deliveryStart': '2024-11-05T15:00:00Z', + 'deliveryEnd': '2025-10-01T02:15:00Z', + 'deliveryStart': '2025-10-01T02:00:00Z', 'entryPerArea': dict({ - 'SE3': 1816.45, - 'SE4': 2522.55, + 'SE3': 441.96, + 'SE4': 517.34, }), }), dict({ - 'deliveryEnd': '2024-11-05T17:00:00Z', - 'deliveryStart': '2024-11-05T16:00:00Z', + 'deliveryEnd': '2025-10-01T02:30:00Z', + 'deliveryStart': '2025-10-01T02:15:00Z', 'entryPerArea': dict({ - 'SE3': 2512.65, - 'SE4': 3533.03, + 'SE3': 483.3, + 'SE4': 559.11, }), }), dict({ - 'deliveryEnd': '2024-11-05T18:00:00Z', - 'deliveryStart': '2024-11-05T17:00:00Z', + 'deliveryEnd': '2025-10-01T02:45:00Z', + 'deliveryStart': '2025-10-01T02:30:00Z', 'entryPerArea': dict({ - 'SE3': 1819.83, - 'SE4': 2524.06, + 'SE3': 484.29, + 'SE4': 559.0, }), }), dict({ - 'deliveryEnd': '2024-11-05T19:00:00Z', - 'deliveryStart': '2024-11-05T18:00:00Z', + 'deliveryEnd': '2025-10-01T03:00:00Z', + 'deliveryStart': '2025-10-01T02:45:00Z', 'entryPerArea': dict({ - 'SE3': 1011.77, + 'SE3': 574.7, + 'SE4': 659.35, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T03:15:00Z', + 'deliveryStart': '2025-10-01T03:00:00Z', + 'entryPerArea': dict({ + 'SE3': 543.31, + 'SE4': 631.95, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T03:30:00Z', + 'deliveryStart': '2025-10-01T03:15:00Z', + 'entryPerArea': dict({ + 'SE3': 578.01, + 'SE4': 671.18, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T03:45:00Z', + 'deliveryStart': '2025-10-01T03:30:00Z', + 'entryPerArea': dict({ + 'SE3': 774.96, + 'SE4': 893.1, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T04:00:00Z', + 'deliveryStart': '2025-10-01T03:45:00Z', + 'entryPerArea': dict({ + 'SE3': 787.0, + 'SE4': 909.79, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T04:15:00Z', + 'deliveryStart': '2025-10-01T04:00:00Z', + 'entryPerArea': dict({ + 'SE3': 902.38, + 'SE4': 1041.86, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T04:30:00Z', + 'deliveryStart': '2025-10-01T04:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1079.32, + 'SE4': 1254.17, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T04:45:00Z', + 'deliveryStart': '2025-10-01T04:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1222.67, + 'SE4': 1421.93, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T05:00:00Z', + 'deliveryStart': '2025-10-01T04:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1394.63, + 'SE4': 1623.08, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T05:15:00Z', + 'deliveryStart': '2025-10-01T05:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1529.36, + 'SE4': 1787.86, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T05:30:00Z', + 'deliveryStart': '2025-10-01T05:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1724.53, + 'SE4': 2015.75, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T05:45:00Z', + 'deliveryStart': '2025-10-01T05:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1809.96, + 'SE4': 2029.34, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T06:00:00Z', + 'deliveryStart': '2025-10-01T05:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1713.04, + 'SE4': 1920.15, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T06:15:00Z', + 'deliveryStart': '2025-10-01T06:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1925.9, + 'SE4': 2162.63, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T06:30:00Z', + 'deliveryStart': '2025-10-01T06:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1440.06, + 'SE4': 1614.01, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T06:45:00Z', + 'deliveryStart': '2025-10-01T06:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1183.32, + 'SE4': 1319.37, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T07:00:00Z', + 'deliveryStart': '2025-10-01T06:45:00Z', + 'entryPerArea': dict({ + 'SE3': 962.95, + 'SE4': 1068.71, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T07:15:00Z', + 'deliveryStart': '2025-10-01T07:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1402.04, + 'SE4': 1569.92, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T07:30:00Z', + 'deliveryStart': '2025-10-01T07:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1060.65, + 'SE4': 1178.46, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T07:45:00Z', + 'deliveryStart': '2025-10-01T07:30:00Z', + 'entryPerArea': dict({ + 'SE3': 949.13, + 'SE4': 1050.59, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T08:00:00Z', + 'deliveryStart': '2025-10-01T07:45:00Z', + 'entryPerArea': dict({ + 'SE3': 841.82, + 'SE4': 938.3, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T08:15:00Z', + 'deliveryStart': '2025-10-01T08:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1037.44, + 'SE4': 1141.44, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T08:30:00Z', + 'deliveryStart': '2025-10-01T08:15:00Z', + 'entryPerArea': dict({ + 'SE3': 950.13, + 'SE4': 1041.64, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T08:45:00Z', + 'deliveryStart': '2025-10-01T08:30:00Z', + 'entryPerArea': dict({ + 'SE3': 826.13, + 'SE4': 905.04, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T09:00:00Z', + 'deliveryStart': '2025-10-01T08:45:00Z', + 'entryPerArea': dict({ + 'SE3': 684.55, + 'SE4': 754.62, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T09:15:00Z', + 'deliveryStart': '2025-10-01T09:00:00Z', + 'entryPerArea': dict({ + 'SE3': 861.6, + 'SE4': 936.09, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T09:30:00Z', + 'deliveryStart': '2025-10-01T09:15:00Z', + 'entryPerArea': dict({ + 'SE3': 722.79, + 'SE4': 799.6, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T09:45:00Z', + 'deliveryStart': '2025-10-01T09:30:00Z', + 'entryPerArea': dict({ + 'SE3': 640.57, + 'SE4': 718.59, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T10:00:00Z', + 'deliveryStart': '2025-10-01T09:45:00Z', + 'entryPerArea': dict({ + 'SE3': 607.74, + 'SE4': 683.12, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T10:15:00Z', + 'deliveryStart': '2025-10-01T10:00:00Z', + 'entryPerArea': dict({ + 'SE3': 674.05, + 'SE4': 752.41, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T10:30:00Z', + 'deliveryStart': '2025-10-01T10:15:00Z', + 'entryPerArea': dict({ + 'SE3': 638.58, + 'SE4': 717.49, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T10:45:00Z', + 'deliveryStart': '2025-10-01T10:30:00Z', + 'entryPerArea': dict({ + 'SE3': 638.47, + 'SE4': 719.81, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T11:00:00Z', + 'deliveryStart': '2025-10-01T10:45:00Z', + 'entryPerArea': dict({ + 'SE3': 634.82, + 'SE4': 717.16, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T11:15:00Z', + 'deliveryStart': '2025-10-01T11:00:00Z', + 'entryPerArea': dict({ + 'SE3': 637.36, + 'SE4': 721.58, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T11:30:00Z', + 'deliveryStart': '2025-10-01T11:15:00Z', + 'entryPerArea': dict({ + 'SE3': 660.68, + 'SE4': 746.33, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T11:45:00Z', + 'deliveryStart': '2025-10-01T11:30:00Z', + 'entryPerArea': dict({ + 'SE3': 679.14, + 'SE4': 766.45, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T12:00:00Z', + 'deliveryStart': '2025-10-01T11:45:00Z', + 'entryPerArea': dict({ + 'SE3': 694.61, + 'SE4': 782.91, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T12:15:00Z', + 'deliveryStart': '2025-10-01T12:00:00Z', + 'entryPerArea': dict({ + 'SE3': 622.33, + 'SE4': 708.87, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T12:30:00Z', + 'deliveryStart': '2025-10-01T12:15:00Z', + 'entryPerArea': dict({ + 'SE3': 685.44, + 'SE4': 775.84, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T12:45:00Z', + 'deliveryStart': '2025-10-01T12:30:00Z', + 'entryPerArea': dict({ + 'SE3': 732.85, + 'SE4': 826.57, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T13:00:00Z', + 'deliveryStart': '2025-10-01T12:45:00Z', + 'entryPerArea': dict({ + 'SE3': 801.92, + 'SE4': 901.28, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T13:15:00Z', + 'deliveryStart': '2025-10-01T13:00:00Z', + 'entryPerArea': dict({ + 'SE3': 629.4, + 'SE4': 717.93, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T13:30:00Z', + 'deliveryStart': '2025-10-01T13:15:00Z', + 'entryPerArea': dict({ + 'SE3': 729.53, + 'SE4': 825.46, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T13:45:00Z', + 'deliveryStart': '2025-10-01T13:30:00Z', + 'entryPerArea': dict({ + 'SE3': 884.81, + 'SE4': 983.95, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T14:00:00Z', + 'deliveryStart': '2025-10-01T13:45:00Z', + 'entryPerArea': dict({ + 'SE3': 984.94, + 'SE4': 1089.71, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T14:15:00Z', + 'deliveryStart': '2025-10-01T14:00:00Z', + 'entryPerArea': dict({ + 'SE3': 615.26, + 'SE4': 703.12, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T14:30:00Z', + 'deliveryStart': '2025-10-01T14:15:00Z', + 'entryPerArea': dict({ + 'SE3': 902.94, + 'SE4': 1002.74, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T14:45:00Z', + 'deliveryStart': '2025-10-01T14:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1043.85, + 'SE4': 1158.35, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T15:00:00Z', + 'deliveryStart': '2025-10-01T14:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1075.12, + 'SE4': 1194.15, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T15:15:00Z', + 'deliveryStart': '2025-10-01T15:00:00Z', + 'entryPerArea': dict({ + 'SE3': 980.52, + 'SE4': 1089.38, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T15:30:00Z', + 'deliveryStart': '2025-10-01T15:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1162.66, + 'SE4': 1300.14, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T15:45:00Z', + 'deliveryStart': '2025-10-01T15:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1453.87, + 'SE4': 1628.6, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T16:00:00Z', + 'deliveryStart': '2025-10-01T15:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1955.96, + 'SE4': 2193.35, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T16:15:00Z', + 'deliveryStart': '2025-10-01T16:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1423.48, + 'SE4': 1623.74, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T16:30:00Z', + 'deliveryStart': '2025-10-01T16:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1900.04, + 'SE4': 2199.98, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T16:45:00Z', + 'deliveryStart': '2025-10-01T16:30:00Z', + 'entryPerArea': dict({ + 'SE3': 2611.11, + 'SE4': 3031.08, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T17:00:00Z', + 'deliveryStart': '2025-10-01T16:45:00Z', + 'entryPerArea': dict({ + 'SE3': 3467.41, + 'SE4': 4029.51, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T17:15:00Z', + 'deliveryStart': '2025-10-01T17:00:00Z', + 'entryPerArea': dict({ + 'SE3': 3828.03, + 'SE4': 4442.74, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T17:30:00Z', + 'deliveryStart': '2025-10-01T17:15:00Z', + 'entryPerArea': dict({ + 'SE3': 3429.83, + 'SE4': 3982.21, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T17:45:00Z', + 'deliveryStart': '2025-10-01T17:30:00Z', + 'entryPerArea': dict({ + 'SE3': 2934.38, + 'SE4': 3405.74, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T18:00:00Z', + 'deliveryStart': '2025-10-01T17:45:00Z', + 'entryPerArea': dict({ + 'SE3': 2308.07, + 'SE4': 2677.64, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T18:15:00Z', + 'deliveryStart': '2025-10-01T18:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1997.96, 'SE4': 0.0, }), }), dict({ - 'deliveryEnd': '2024-11-05T20:00:00Z', - 'deliveryStart': '2024-11-05T19:00:00Z', + 'deliveryEnd': '2025-10-01T18:30:00Z', + 'deliveryStart': '2025-10-01T18:15:00Z', 'entryPerArea': dict({ - 'SE3': 835.53, - 'SE4': 1112.57, + 'SE3': 1424.03, + 'SE4': 1646.17, }), }), dict({ - 'deliveryEnd': '2024-11-05T21:00:00Z', - 'deliveryStart': '2024-11-05T20:00:00Z', + 'deliveryEnd': '2025-10-01T18:45:00Z', + 'deliveryStart': '2025-10-01T18:30:00Z', 'entryPerArea': dict({ - 'SE3': 796.19, - 'SE4': 1051.69, + 'SE3': 1216.81, + 'SE4': 1388.11, }), }), dict({ - 'deliveryEnd': '2024-11-05T22:00:00Z', - 'deliveryStart': '2024-11-05T21:00:00Z', + 'deliveryEnd': '2025-10-01T19:00:00Z', + 'deliveryStart': '2025-10-01T18:45:00Z', 'entryPerArea': dict({ - 'SE3': 522.3, - 'SE4': 662.44, + 'SE3': 1070.15, + 'SE4': 1204.65, }), }), dict({ - 'deliveryEnd': '2024-11-05T23:00:00Z', - 'deliveryStart': '2024-11-05T22:00:00Z', + 'deliveryEnd': '2025-10-01T19:15:00Z', + 'deliveryStart': '2025-10-01T19:00:00Z', 'entryPerArea': dict({ - 'SE3': 289.14, - 'SE4': 349.21, + 'SE3': 1218.14, + 'SE4': 1405.02, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T19:30:00Z', + 'deliveryStart': '2025-10-01T19:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1135.8, + 'SE4': 1309.42, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T19:45:00Z', + 'deliveryStart': '2025-10-01T19:30:00Z', + 'entryPerArea': dict({ + 'SE3': 959.96, + 'SE4': 1115.69, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T20:00:00Z', + 'deliveryStart': '2025-10-01T19:45:00Z', + 'entryPerArea': dict({ + 'SE3': 913.66, + 'SE4': 1064.52, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T20:15:00Z', + 'deliveryStart': '2025-10-01T20:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1001.63, + 'SE4': 1161.22, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T20:30:00Z', + 'deliveryStart': '2025-10-01T20:15:00Z', + 'entryPerArea': dict({ + 'SE3': 933.0, + 'SE4': 1083.08, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T20:45:00Z', + 'deliveryStart': '2025-10-01T20:30:00Z', + 'entryPerArea': dict({ + 'SE3': 874.53, + 'SE4': 1017.66, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T21:00:00Z', + 'deliveryStart': '2025-10-01T20:45:00Z', + 'entryPerArea': dict({ + 'SE3': 821.71, + 'SE4': 955.32, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T21:15:00Z', + 'deliveryStart': '2025-10-01T21:00:00Z', + 'entryPerArea': dict({ + 'SE3': 860.5, + 'SE4': 997.32, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T21:30:00Z', + 'deliveryStart': '2025-10-01T21:15:00Z', + 'entryPerArea': dict({ + 'SE3': 840.16, + 'SE4': 977.87, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T21:45:00Z', + 'deliveryStart': '2025-10-01T21:30:00Z', + 'entryPerArea': dict({ + 'SE3': 820.05, + 'SE4': 954.66, + }), + }), + dict({ + 'deliveryEnd': '2025-10-01T22:00:00Z', + 'deliveryStart': '2025-10-01T21:45:00Z', + 'entryPerArea': dict({ + 'SE3': 785.68, + 'SE4': 912.22, }), }), ]), - 'updatedAt': '2024-11-04T12:15:03.9456464Z', + 'updatedAt': '2025-09-30T12:08:16.4448023Z', 'version': 3, }), - '2024-11-06': dict({ + '2025-10-02': dict({ 'areaAverages': list([ dict({ 'areaCode': 'SE3', - 'price': 900.65, + 'price': 1129.65, }), dict({ 'areaCode': 'SE4', - 'price': 1581.19, + 'price': 1119.28, }), ]), 'areaStates': list([ @@ -582,53 +1158,53 @@ dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 422.51, - 'max': 1820.5, - 'min': 74.06, + 'average': 961.76, + 'max': 1831.25, + 'min': 673.05, }), 'SE4': dict({ - 'average': 706.61, - 'max': 2449.96, - 'min': 157.34, + 'average': 1102.25, + 'max': 2182.34, + 'min': 758.78, }), }), 'blockName': 'Off-peak 1', - 'deliveryEnd': '2024-11-06T07:00:00Z', - 'deliveryStart': '2024-11-05T23:00:00Z', + 'deliveryEnd': '2025-10-02T06:00:00Z', + 'deliveryStart': '2025-10-01T22:00:00Z', }), dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 1346.82, - 'max': 2366.57, - 'min': 903.31, + 'average': 1191.34, + 'max': 3288.35, + 'min': 563.38, }), 'SE4': dict({ - 'average': 2306.88, - 'max': 5511.77, - 'min': 1362.84, + 'average': 1155.07, + 'max': 2617.73, + 'min': 635.76, }), }), 'blockName': 'Peak', - 'deliveryEnd': '2024-11-06T19:00:00Z', - 'deliveryStart': '2024-11-06T07:00:00Z', + 'deliveryEnd': '2025-10-02T18:00:00Z', + 'deliveryStart': '2025-10-02T06:00:00Z', }), dict({ 'averagePricePerArea': dict({ 'SE3': dict({ - 'average': 518.43, - 'max': 716.82, - 'min': 250.64, + 'average': 1280.38, + 'max': 1935.08, + 'min': 646.9, }), 'SE4': dict({ - 'average': 1153.25, - 'max': 1624.33, - 'min': 539.42, + 'average': 1045.99, + 'max': 1532.57, + 'min': 591.84, }), }), 'blockName': 'Off-peak 2', - 'deliveryEnd': '2024-11-06T23:00:00Z', - 'deliveryStart': '2024-11-06T19:00:00Z', + 'deliveryEnd': '2025-10-02T22:00:00Z', + 'deliveryStart': '2025-10-02T18:00:00Z', }), ]), 'currency': 'SEK', @@ -636,204 +1212,780 @@ 'SE3', 'SE4', ]), - 'deliveryDateCET': '2024-11-06', - 'exchangeRate': 11.66314, + 'deliveryDateCET': '2025-10-02', + 'exchangeRate': 11.03362, 'market': 'DayAhead', 'multiAreaEntries': list([ dict({ - 'deliveryEnd': '2024-11-06T00:00:00Z', - 'deliveryStart': '2024-11-05T23:00:00Z', + 'deliveryEnd': '2025-10-01T22:15:00Z', + 'deliveryStart': '2025-10-01T22:00:00Z', 'entryPerArea': dict({ - 'SE3': 126.66, - 'SE4': 275.6, + 'SE3': 933.22, + 'SE4': 1062.32, }), }), dict({ - 'deliveryEnd': '2024-11-06T01:00:00Z', - 'deliveryStart': '2024-11-06T00:00:00Z', + 'deliveryEnd': '2025-10-01T22:30:00Z', + 'deliveryStart': '2025-10-01T22:15:00Z', 'entryPerArea': dict({ - 'SE3': 74.06, - 'SE4': 157.34, + 'SE3': 854.22, + 'SE4': 971.95, }), }), dict({ - 'deliveryEnd': '2024-11-06T02:00:00Z', - 'deliveryStart': '2024-11-06T01:00:00Z', + 'deliveryEnd': '2025-10-01T22:45:00Z', + 'deliveryStart': '2025-10-01T22:30:00Z', 'entryPerArea': dict({ - 'SE3': 78.38, - 'SE4': 165.62, + 'SE3': 809.54, + 'SE4': 919.1, }), }), dict({ - 'deliveryEnd': '2024-11-06T03:00:00Z', - 'deliveryStart': '2024-11-06T02:00:00Z', + 'deliveryEnd': '2025-10-01T23:00:00Z', + 'deliveryStart': '2025-10-01T22:45:00Z', 'entryPerArea': dict({ - 'SE3': 92.37, - 'SE4': 196.17, + 'SE3': 811.74, + 'SE4': 922.63, }), }), dict({ - 'deliveryEnd': '2024-11-06T04:00:00Z', - 'deliveryStart': '2024-11-06T03:00:00Z', + 'deliveryEnd': '2025-10-01T23:15:00Z', + 'deliveryStart': '2025-10-01T23:00:00Z', 'entryPerArea': dict({ - 'SE3': 99.14, - 'SE4': 190.58, + 'SE3': 835.13, + 'SE4': 950.99, }), }), dict({ - 'deliveryEnd': '2024-11-06T05:00:00Z', - 'deliveryStart': '2024-11-06T04:00:00Z', + 'deliveryEnd': '2025-10-01T23:30:00Z', + 'deliveryStart': '2025-10-01T23:15:00Z', 'entryPerArea': dict({ - 'SE3': 447.51, - 'SE4': 932.93, + 'SE3': 828.85, + 'SE4': 942.82, }), }), dict({ - 'deliveryEnd': '2024-11-06T06:00:00Z', - 'deliveryStart': '2024-11-06T05:00:00Z', + 'deliveryEnd': '2025-10-01T23:45:00Z', + 'deliveryStart': '2025-10-01T23:30:00Z', 'entryPerArea': dict({ - 'SE3': 641.47, - 'SE4': 1284.69, + 'SE3': 796.63, + 'SE4': 903.54, }), }), dict({ - 'deliveryEnd': '2024-11-06T07:00:00Z', - 'deliveryStart': '2024-11-06T06:00:00Z', + 'deliveryEnd': '2025-10-02T00:00:00Z', + 'deliveryStart': '2025-10-01T23:45:00Z', 'entryPerArea': dict({ - 'SE3': 1820.5, - 'SE4': 2449.96, + 'SE3': 706.7, + 'SE4': 799.61, }), }), dict({ - 'deliveryEnd': '2024-11-06T08:00:00Z', - 'deliveryStart': '2024-11-06T07:00:00Z', + 'deliveryEnd': '2025-10-02T00:15:00Z', + 'deliveryStart': '2025-10-02T00:00:00Z', 'entryPerArea': dict({ - 'SE3': 1723.0, - 'SE4': 2244.22, + 'SE3': 695.23, + 'SE4': 786.81, }), }), dict({ - 'deliveryEnd': '2024-11-06T09:00:00Z', - 'deliveryStart': '2024-11-06T08:00:00Z', + 'deliveryEnd': '2025-10-02T00:30:00Z', + 'deliveryStart': '2025-10-02T00:15:00Z', 'entryPerArea': dict({ - 'SE3': 1298.57, - 'SE4': 1643.45, + 'SE3': 695.12, + 'SE4': 783.83, }), }), dict({ - 'deliveryEnd': '2024-11-06T10:00:00Z', - 'deliveryStart': '2024-11-06T09:00:00Z', + 'deliveryEnd': '2025-10-02T00:45:00Z', + 'deliveryStart': '2025-10-02T00:30:00Z', 'entryPerArea': dict({ - 'SE3': 1099.25, - 'SE4': 1507.23, + 'SE3': 684.86, + 'SE4': 771.8, }), }), dict({ - 'deliveryEnd': '2024-11-06T11:00:00Z', - 'deliveryStart': '2024-11-06T10:00:00Z', + 'deliveryEnd': '2025-10-02T01:00:00Z', + 'deliveryStart': '2025-10-02T00:45:00Z', 'entryPerArea': dict({ - 'SE3': 903.31, - 'SE4': 1362.84, + 'SE3': 673.05, + 'SE4': 758.78, }), }), dict({ - 'deliveryEnd': '2024-11-06T12:00:00Z', - 'deliveryStart': '2024-11-06T11:00:00Z', + 'deliveryEnd': '2025-10-02T01:15:00Z', + 'deliveryStart': '2025-10-02T01:00:00Z', 'entryPerArea': dict({ - 'SE3': 959.99, - 'SE4': 1376.13, + 'SE3': 695.01, + 'SE4': 791.22, }), }), dict({ - 'deliveryEnd': '2024-11-06T13:00:00Z', - 'deliveryStart': '2024-11-06T12:00:00Z', + 'deliveryEnd': '2025-10-02T01:30:00Z', + 'deliveryStart': '2025-10-02T01:15:00Z', 'entryPerArea': dict({ - 'SE3': 1186.61, - 'SE4': 1449.96, + 'SE3': 693.35, + 'SE4': 789.12, }), }), dict({ - 'deliveryEnd': '2024-11-06T14:00:00Z', - 'deliveryStart': '2024-11-06T13:00:00Z', + 'deliveryEnd': '2025-10-02T01:45:00Z', + 'deliveryStart': '2025-10-02T01:30:00Z', 'entryPerArea': dict({ - 'SE3': 1307.67, - 'SE4': 1608.35, + 'SE3': 702.4, + 'SE4': 799.61, }), }), dict({ - 'deliveryEnd': '2024-11-06T15:00:00Z', - 'deliveryStart': '2024-11-06T14:00:00Z', + 'deliveryEnd': '2025-10-02T02:00:00Z', + 'deliveryStart': '2025-10-02T01:45:00Z', 'entryPerArea': dict({ - 'SE3': 1385.46, - 'SE4': 2110.8, + 'SE3': 749.4, + 'SE4': 853.45, }), }), dict({ - 'deliveryEnd': '2024-11-06T16:00:00Z', - 'deliveryStart': '2024-11-06T15:00:00Z', + 'deliveryEnd': '2025-10-02T02:15:00Z', + 'deliveryStart': '2025-10-02T02:00:00Z', 'entryPerArea': dict({ - 'SE3': 1366.8, - 'SE4': 3031.25, + 'SE3': 796.85, + 'SE4': 907.4, }), }), dict({ - 'deliveryEnd': '2024-11-06T17:00:00Z', - 'deliveryStart': '2024-11-06T16:00:00Z', + 'deliveryEnd': '2025-10-02T02:30:00Z', + 'deliveryStart': '2025-10-02T02:15:00Z', 'entryPerArea': dict({ - 'SE3': 2366.57, - 'SE4': 5511.77, + 'SE3': 811.19, + 'SE4': 924.07, }), }), dict({ - 'deliveryEnd': '2024-11-06T18:00:00Z', - 'deliveryStart': '2024-11-06T17:00:00Z', + 'deliveryEnd': '2025-10-02T02:45:00Z', + 'deliveryStart': '2025-10-02T02:30:00Z', 'entryPerArea': dict({ - 'SE3': 1481.92, - 'SE4': 3351.64, + 'SE3': 803.8, + 'SE4': 916.23, }), }), dict({ - 'deliveryEnd': '2024-11-06T19:00:00Z', - 'deliveryStart': '2024-11-06T18:00:00Z', + 'deliveryEnd': '2025-10-02T03:00:00Z', + 'deliveryStart': '2025-10-02T02:45:00Z', 'entryPerArea': dict({ - 'SE3': 1082.69, - 'SE4': 2484.95, + 'SE3': 839.11, + 'SE4': 953.3, }), }), dict({ - 'deliveryEnd': '2024-11-06T20:00:00Z', - 'deliveryStart': '2024-11-06T19:00:00Z', + 'deliveryEnd': '2025-10-02T03:15:00Z', + 'deliveryStart': '2025-10-02T03:00:00Z', 'entryPerArea': dict({ - 'SE3': 716.82, - 'SE4': 1624.33, + 'SE3': 825.2, + 'SE4': 943.15, }), }), dict({ - 'deliveryEnd': '2024-11-06T21:00:00Z', - 'deliveryStart': '2024-11-06T20:00:00Z', + 'deliveryEnd': '2025-10-02T03:30:00Z', + 'deliveryStart': '2025-10-02T03:15:00Z', 'entryPerArea': dict({ - 'SE3': 583.16, - 'SE4': 1306.27, + 'SE3': 838.78, + 'SE4': 958.93, }), }), dict({ - 'deliveryEnd': '2024-11-06T22:00:00Z', - 'deliveryStart': '2024-11-06T21:00:00Z', + 'deliveryEnd': '2025-10-02T03:45:00Z', + 'deliveryStart': '2025-10-02T03:30:00Z', 'entryPerArea': dict({ - 'SE3': 523.09, - 'SE4': 1142.99, + 'SE3': 906.19, + 'SE4': 1030.65, }), }), dict({ - 'deliveryEnd': '2024-11-06T23:00:00Z', - 'deliveryStart': '2024-11-06T22:00:00Z', + 'deliveryEnd': '2025-10-02T04:00:00Z', + 'deliveryStart': '2025-10-02T03:45:00Z', 'entryPerArea': dict({ - 'SE3': 250.64, - 'SE4': 539.42, + 'SE3': 1057.79, + 'SE4': 1195.82, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T04:15:00Z', + 'deliveryStart': '2025-10-02T04:00:00Z', + 'entryPerArea': dict({ + 'SE3': 912.15, + 'SE4': 1040.8, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T04:30:00Z', + 'deliveryStart': '2025-10-02T04:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1131.28, + 'SE4': 1283.43, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T04:45:00Z', + 'deliveryStart': '2025-10-02T04:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1294.68, + 'SE4': 1468.91, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T05:00:00Z', + 'deliveryStart': '2025-10-02T04:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1625.8, + 'SE4': 1845.81, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T05:15:00Z', + 'deliveryStart': '2025-10-02T05:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1649.31, + 'SE4': 1946.77, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T05:30:00Z', + 'deliveryStart': '2025-10-02T05:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1831.25, + 'SE4': 2182.34, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T05:45:00Z', + 'deliveryStart': '2025-10-02T05:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1743.31, + 'SE4': 2063.4, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T06:00:00Z', + 'deliveryStart': '2025-10-02T05:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1545.04, + 'SE4': 1803.33, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T06:15:00Z', + 'deliveryStart': '2025-10-02T06:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1783.47, + 'SE4': 2080.72, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T06:30:00Z', + 'deliveryStart': '2025-10-02T06:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1470.89, + 'SE4': 1675.23, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T06:45:00Z', + 'deliveryStart': '2025-10-02T06:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1191.08, + 'SE4': 1288.06, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T07:00:00Z', + 'deliveryStart': '2025-10-02T06:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1012.22, + 'SE4': 1112.19, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T07:15:00Z', + 'deliveryStart': '2025-10-02T07:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1278.69, + 'SE4': 1375.67, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T07:30:00Z', + 'deliveryStart': '2025-10-02T07:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1170.12, + 'SE4': 1258.61, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T07:45:00Z', + 'deliveryStart': '2025-10-02T07:30:00Z', + 'entryPerArea': dict({ + 'SE3': 937.09, + 'SE4': 1021.93, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T08:00:00Z', + 'deliveryStart': '2025-10-02T07:45:00Z', + 'entryPerArea': dict({ + 'SE3': 815.94, + 'SE4': 900.67, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T08:15:00Z', + 'deliveryStart': '2025-10-02T08:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1044.66, + 'SE4': 1135.25, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T08:30:00Z', + 'deliveryStart': '2025-10-02T08:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1020.61, + 'SE4': 1112.74, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T08:45:00Z', + 'deliveryStart': '2025-10-02T08:30:00Z', + 'entryPerArea': dict({ + 'SE3': 866.14, + 'SE4': 953.53, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T09:00:00Z', + 'deliveryStart': '2025-10-02T08:45:00Z', + 'entryPerArea': dict({ + 'SE3': 774.34, + 'SE4': 860.18, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T09:15:00Z', + 'deliveryStart': '2025-10-02T09:00:00Z', + 'entryPerArea': dict({ + 'SE3': 928.26, + 'SE4': 1020.39, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T09:30:00Z', + 'deliveryStart': '2025-10-02T09:15:00Z', + 'entryPerArea': dict({ + 'SE3': 834.47, + 'SE4': 922.96, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T09:45:00Z', + 'deliveryStart': '2025-10-02T09:30:00Z', + 'entryPerArea': dict({ + 'SE3': 712.33, + 'SE4': 794.64, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T10:00:00Z', + 'deliveryStart': '2025-10-02T09:45:00Z', + 'entryPerArea': dict({ + 'SE3': 646.46, + 'SE4': 725.9, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T10:15:00Z', + 'deliveryStart': '2025-10-02T10:00:00Z', + 'entryPerArea': dict({ + 'SE3': 692.91, + 'SE4': 773.9, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T10:30:00Z', + 'deliveryStart': '2025-10-02T10:15:00Z', + 'entryPerArea': dict({ + 'SE3': 627.59, + 'SE4': 706.59, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T10:45:00Z', + 'deliveryStart': '2025-10-02T10:30:00Z', + 'entryPerArea': dict({ + 'SE3': 630.02, + 'SE4': 708.14, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T11:00:00Z', + 'deliveryStart': '2025-10-02T10:45:00Z', + 'entryPerArea': dict({ + 'SE3': 625.94, + 'SE4': 703.61, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T11:15:00Z', + 'deliveryStart': '2025-10-02T11:00:00Z', + 'entryPerArea': dict({ + 'SE3': 563.38, + 'SE4': 635.76, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T11:30:00Z', + 'deliveryStart': '2025-10-02T11:15:00Z', + 'entryPerArea': dict({ + 'SE3': 588.42, + 'SE4': 663.12, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T11:45:00Z', + 'deliveryStart': '2025-10-02T11:30:00Z', + 'entryPerArea': dict({ + 'SE3': 597.03, + 'SE4': 672.83, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T12:00:00Z', + 'deliveryStart': '2025-10-02T11:45:00Z', + 'entryPerArea': dict({ + 'SE3': 608.61, + 'SE4': 685.19, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T12:15:00Z', + 'deliveryStart': '2025-10-02T12:00:00Z', + 'entryPerArea': dict({ + 'SE3': 599.24, + 'SE4': 676.91, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T12:30:00Z', + 'deliveryStart': '2025-10-02T12:15:00Z', + 'entryPerArea': dict({ + 'SE3': 649.77, + 'SE4': 729.54, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T12:45:00Z', + 'deliveryStart': '2025-10-02T12:30:00Z', + 'entryPerArea': dict({ + 'SE3': 728.22, + 'SE4': 821.23, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T13:00:00Z', + 'deliveryStart': '2025-10-02T12:45:00Z', + 'entryPerArea': dict({ + 'SE3': 803.91, + 'SE4': 909.06, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T13:15:00Z', + 'deliveryStart': '2025-10-02T13:00:00Z', + 'entryPerArea': dict({ + 'SE3': 594.38, + 'SE4': 679.23, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T13:30:00Z', + 'deliveryStart': '2025-10-02T13:15:00Z', + 'entryPerArea': dict({ + 'SE3': 738.48, + 'SE4': 825.09, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T13:45:00Z', + 'deliveryStart': '2025-10-02T13:30:00Z', + 'entryPerArea': dict({ + 'SE3': 873.53, + 'SE4': 962.02, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T14:00:00Z', + 'deliveryStart': '2025-10-02T13:45:00Z', + 'entryPerArea': dict({ + 'SE3': 994.57, + 'SE4': 1083.5, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T14:15:00Z', + 'deliveryStart': '2025-10-02T14:00:00Z', + 'entryPerArea': dict({ + 'SE3': 733.52, + 'SE4': 813.18, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T14:30:00Z', + 'deliveryStart': '2025-10-02T14:15:00Z', + 'entryPerArea': dict({ + 'SE3': 864.59, + 'SE4': 944.04, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T14:45:00Z', + 'deliveryStart': '2025-10-02T14:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1032.08, + 'SE4': 1113.18, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T15:00:00Z', + 'deliveryStart': '2025-10-02T14:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1153.01, + 'SE4': 1241.61, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T15:15:00Z', + 'deliveryStart': '2025-10-02T15:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1271.18, + 'SE4': 1017.41, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T15:30:00Z', + 'deliveryStart': '2025-10-02T15:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1375.23, + 'SE4': 1093.1, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T15:45:00Z', + 'deliveryStart': '2025-10-02T15:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1544.82, + 'SE4': 1244.81, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T16:00:00Z', + 'deliveryStart': '2025-10-02T15:45:00Z', + 'entryPerArea': dict({ + 'SE3': 2412.17, + 'SE4': 1960.12, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T16:15:00Z', + 'deliveryStart': '2025-10-02T16:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1677.66, + 'SE4': 1334.3, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T16:30:00Z', + 'deliveryStart': '2025-10-02T16:15:00Z', + 'entryPerArea': dict({ + 'SE3': 2010.55, + 'SE4': 1606.61, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T16:45:00Z', + 'deliveryStart': '2025-10-02T16:30:00Z', + 'entryPerArea': dict({ + 'SE3': 2524.38, + 'SE4': 2013.53, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T17:00:00Z', + 'deliveryStart': '2025-10-02T16:45:00Z', + 'entryPerArea': dict({ + 'SE3': 3288.35, + 'SE4': 2617.73, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T17:15:00Z', + 'deliveryStart': '2025-10-02T17:00:00Z', + 'entryPerArea': dict({ + 'SE3': 3065.69, + 'SE4': 2472.19, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T17:30:00Z', + 'deliveryStart': '2025-10-02T17:15:00Z', + 'entryPerArea': dict({ + 'SE3': 2824.72, + 'SE4': 2276.46, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T17:45:00Z', + 'deliveryStart': '2025-10-02T17:30:00Z', + 'entryPerArea': dict({ + 'SE3': 2279.66, + 'SE4': 1835.44, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T18:00:00Z', + 'deliveryStart': '2025-10-02T17:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1723.78, + 'SE4': 1385.38, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T18:15:00Z', + 'deliveryStart': '2025-10-02T18:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1935.08, + 'SE4': 1532.57, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T18:30:00Z', + 'deliveryStart': '2025-10-02T18:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1568.54, + 'SE4': 1240.18, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T18:45:00Z', + 'deliveryStart': '2025-10-02T18:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1430.51, + 'SE4': 1115.61, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T19:00:00Z', + 'deliveryStart': '2025-10-02T18:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1377.66, + 'SE4': 1075.12, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T19:15:00Z', + 'deliveryStart': '2025-10-02T19:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1408.44, + 'SE4': 1108.66, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T19:30:00Z', + 'deliveryStart': '2025-10-02T19:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1326.79, + 'SE4': 1049.74, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T19:45:00Z', + 'deliveryStart': '2025-10-02T19:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1210.94, + 'SE4': 951.1, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T20:00:00Z', + 'deliveryStart': '2025-10-02T19:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1293.58, + 'SE4': 1026.79, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T20:15:00Z', + 'deliveryStart': '2025-10-02T20:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1385.71, + 'SE4': 1091.0, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T20:30:00Z', + 'deliveryStart': '2025-10-02T20:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1341.47, + 'SE4': 1104.13, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T20:45:00Z', + 'deliveryStart': '2025-10-02T20:30:00Z', + 'entryPerArea': dict({ + 'SE3': 1284.98, + 'SE4': 1024.36, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T21:00:00Z', + 'deliveryStart': '2025-10-02T20:45:00Z', + 'entryPerArea': dict({ + 'SE3': 1071.47, + 'SE4': 892.51, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T21:15:00Z', + 'deliveryStart': '2025-10-02T21:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1218.0, + 'SE4': 1123.99, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T21:30:00Z', + 'deliveryStart': '2025-10-02T21:15:00Z', + 'entryPerArea': dict({ + 'SE3': 1112.3, + 'SE4': 1001.63, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T21:45:00Z', + 'deliveryStart': '2025-10-02T21:30:00Z', + 'entryPerArea': dict({ + 'SE3': 873.64, + 'SE4': 806.67, + }), + }), + dict({ + 'deliveryEnd': '2025-10-02T22:00:00Z', + 'deliveryStart': '2025-10-02T21:45:00Z', + 'entryPerArea': dict({ + 'SE3': 646.9, + 'SE4': 591.84, }), }), ]), - 'updatedAt': '2024-11-05T12:12:51.9853434Z', + 'updatedAt': '2025-10-01T11:25:06.1484362Z', 'version': 3, }), }), diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index 232836d1cc9..b2a53981fbc 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -99,7 +99,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.01177', + 'state': '1.99796', }) # --- # name: test_sensor[sensor.nord_pool_se3_daily_average-entry] @@ -154,7 +154,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.90074', + 'state': '1.03398', }) # --- # name: test_sensor[sensor.nord_pool_se3_exchange_rate-entry] @@ -205,7 +205,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '11.6402', + 'state': '11.05186', }) # --- # name: test_sensor[sensor.nord_pool_se3_highest_price-entry] @@ -249,9 +249,9 @@ # name: test_sensor[sensor.nord_pool_se3_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'end': '2024-11-05T17:00:00+00:00', + 'end': '2025-10-01T17:15:00+00:00', 'friendly_name': 'Nord Pool SE3 Highest price', - 'start': '2024-11-05T16:00:00+00:00', + 'start': '2025-10-01T17:00:00+00:00', 'unit_of_measurement': 'SEK/kWh', }), 'context': , @@ -259,7 +259,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.51265', + 'state': '3.82803', }) # --- # name: test_sensor[sensor.nord_pool_se3_last_updated-entry] @@ -308,7 +308,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-04T12:15:03+00:00', + 'state': '2025-09-30T12:08:16+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se3_lowest_price-entry] @@ -352,9 +352,9 @@ # name: test_sensor[sensor.nord_pool_se3_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'end': '2024-11-05T03:00:00+00:00', + 'end': '2025-10-01T02:15:00+00:00', 'friendly_name': 'Nord Pool SE3 Lowest price', - 'start': '2024-11-05T02:00:00+00:00', + 'start': '2025-10-01T02:00:00+00:00', 'unit_of_measurement': 'SEK/kWh', }), 'context': , @@ -362,7 +362,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06169', + 'state': '0.44196', }) # --- # name: test_sensor[sensor.nord_pool_se3_next_price-entry] @@ -414,7 +414,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.83553', + 'state': '1.21814', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_1_average-entry] @@ -469,7 +469,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.42287', + 'state': '0.74593', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_1_highest_price-entry] @@ -524,7 +524,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.40614', + 'state': '1.80996', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_1_lowest_price-entry] @@ -579,7 +579,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06169', + 'state': '0.44196', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_from-entry] @@ -628,7 +628,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-04T23:00:00+00:00', + 'state': '2025-09-30T22:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_until-entry] @@ -677,7 +677,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T07:00:00+00:00', + 'state': '2025-10-01T06:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_2_average-entry] @@ -732,7 +732,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.61079', + 'state': '1.05461', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_2_highest_price-entry] @@ -787,7 +787,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.83553', + 'state': '1.99796', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_2_lowest_price-entry] @@ -842,7 +842,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.28914', + 'state': '0.78568', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_from-entry] @@ -891,7 +891,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T19:00:00+00:00', + 'state': '2025-10-01T18:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_until-entry] @@ -940,7 +940,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T23:00:00+00:00', + 'state': '2025-10-01T22:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se3_peak_average-entry] @@ -995,7 +995,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.31597', + 'state': '1.21913', }) # --- # name: test_sensor[sensor.nord_pool_se3_peak_highest_price-entry] @@ -1050,7 +1050,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.51265', + 'state': '3.82803', }) # --- # name: test_sensor[sensor.nord_pool_se3_peak_lowest_price-entry] @@ -1105,7 +1105,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.92505', + 'state': '0.60774', }) # --- # name: test_sensor[sensor.nord_pool_se3_peak_time_from-entry] @@ -1154,7 +1154,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T07:00:00+00:00', + 'state': '2025-10-01T06:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se3_peak_time_until-entry] @@ -1203,7 +1203,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T19:00:00+00:00', + 'state': '2025-10-01T18:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se3_previous_price-entry] @@ -1255,7 +1255,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.81983', + 'state': '3.82803', }) # --- # name: test_sensor[sensor.nord_pool_se4_currency-entry] @@ -1413,7 +1413,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.16612', + 'state': '1.18078', }) # --- # name: test_sensor[sensor.nord_pool_se4_exchange_rate-entry] @@ -1464,7 +1464,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '11.6402', + 'state': '11.05186', }) # --- # name: test_sensor[sensor.nord_pool_se4_highest_price-entry] @@ -1508,9 +1508,9 @@ # name: test_sensor[sensor.nord_pool_se4_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'end': '2024-11-05T17:00:00+00:00', + 'end': '2025-10-01T17:15:00+00:00', 'friendly_name': 'Nord Pool SE4 Highest price', - 'start': '2024-11-05T16:00:00+00:00', + 'start': '2025-10-01T17:00:00+00:00', 'unit_of_measurement': 'SEK/kWh', }), 'context': , @@ -1518,7 +1518,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.53303', + 'state': '4.44274', }) # --- # name: test_sensor[sensor.nord_pool_se4_last_updated-entry] @@ -1567,7 +1567,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-04T12:15:03+00:00', + 'state': '2025-09-30T12:08:16+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se4_lowest_price-entry] @@ -1611,9 +1611,9 @@ # name: test_sensor[sensor.nord_pool_se4_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'end': '2024-11-05T19:00:00+00:00', + 'end': '2025-10-01T18:15:00+00:00', 'friendly_name': 'Nord Pool SE4 Lowest price', - 'start': '2024-11-05T18:00:00+00:00', + 'start': '2025-10-01T18:00:00+00:00', 'unit_of_measurement': 'SEK/kWh', }), 'context': , @@ -1673,7 +1673,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.11257', + 'state': '1.40502', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_1_average-entry] @@ -1728,7 +1728,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.49797', + 'state': '0.86099', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_1_highest_price-entry] @@ -1783,7 +1783,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.64825', + 'state': '2.02934', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_1_lowest_price-entry] @@ -1838,7 +1838,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06519', + 'state': '0.51546', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_from-entry] @@ -1887,7 +1887,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-04T23:00:00+00:00', + 'state': '2025-09-30T22:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_until-entry] @@ -1936,7 +1936,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T07:00:00+00:00', + 'state': '2025-10-01T06:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_2_average-entry] @@ -1991,7 +1991,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.79398', + 'state': '1.21907', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_2_highest_price-entry] @@ -2046,7 +2046,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.11257', + 'state': '2.31216', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_2_lowest_price-entry] @@ -2101,7 +2101,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.34921', + 'state': '0.91222', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_from-entry] @@ -2150,7 +2150,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T19:00:00+00:00', + 'state': '2025-10-01T18:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_until-entry] @@ -2199,7 +2199,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T23:00:00+00:00', + 'state': '2025-10-01T22:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se4_peak_average-entry] @@ -2254,7 +2254,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.73559', + 'state': '1.38122', }) # --- # name: test_sensor[sensor.nord_pool_se4_peak_highest_price-entry] @@ -2309,7 +2309,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.53303', + 'state': '4.44274', }) # --- # name: test_sensor[sensor.nord_pool_se4_peak_lowest_price-entry] @@ -2364,7 +2364,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.08172', + 'state': '0.68312', }) # --- # name: test_sensor[sensor.nord_pool_se4_peak_time_from-entry] @@ -2413,7 +2413,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T07:00:00+00:00', + 'state': '2025-10-01T06:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se4_peak_time_until-entry] @@ -2462,7 +2462,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-11-05T19:00:00+00:00', + 'state': '2025-10-01T18:00:00+00:00', }) # --- # name: test_sensor[sensor.nord_pool_se4_previous_price-entry] @@ -2514,6 +2514,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.52406', + 'state': '4.44274', }) # --- diff --git a/tests/components/nordpool/snapshots/test_services.ambr b/tests/components/nordpool/snapshots/test_services.ambr index 5e39082f647..a478791bd9a 100644 --- a/tests/components/nordpool/snapshots/test_services.ambr +++ b/tests/components/nordpool/snapshots/test_services.ambr @@ -9,124 +9,484 @@ dict({ 'SE3': list([ dict({ - 'end': '2024-11-05T00:00:00+00:00', - 'price': 250.73, - 'start': '2024-11-04T23:00:00+00:00', + 'end': '2025-09-30T22:15:00+00:00', + 'price': 556.68, + 'start': '2025-09-30T22:00:00+00:00', }), dict({ - 'end': '2024-11-05T01:00:00+00:00', - 'price': 76.36, - 'start': '2024-11-05T00:00:00+00:00', + 'end': '2025-09-30T22:30:00+00:00', + 'price': 519.88, + 'start': '2025-09-30T22:15:00+00:00', }), dict({ - 'end': '2024-11-05T02:00:00+00:00', - 'price': 73.92, - 'start': '2024-11-05T01:00:00+00:00', + 'end': '2025-09-30T22:45:00+00:00', + 'price': 508.28, + 'start': '2025-09-30T22:30:00+00:00', }), dict({ - 'end': '2024-11-05T03:00:00+00:00', - 'price': 61.69, - 'start': '2024-11-05T02:00:00+00:00', + 'end': '2025-09-30T23:00:00+00:00', + 'price': 509.93, + 'start': '2025-09-30T22:45:00+00:00', }), dict({ - 'end': '2024-11-05T04:00:00+00:00', - 'price': 64.6, - 'start': '2024-11-05T03:00:00+00:00', + 'end': '2025-09-30T23:15:00+00:00', + 'price': 501.64, + 'start': '2025-09-30T23:00:00+00:00', }), dict({ - 'end': '2024-11-05T05:00:00+00:00', - 'price': 453.27, - 'start': '2024-11-05T04:00:00+00:00', + 'end': '2025-09-30T23:30:00+00:00', + 'price': 509.05, + 'start': '2025-09-30T23:15:00+00:00', }), dict({ - 'end': '2024-11-05T06:00:00+00:00', - 'price': 996.28, - 'start': '2024-11-05T05:00:00+00:00', + 'end': '2025-09-30T23:45:00+00:00', + 'price': 491.03, + 'start': '2025-09-30T23:30:00+00:00', }), dict({ - 'end': '2024-11-05T07:00:00+00:00', - 'price': 1406.14, - 'start': '2024-11-05T06:00:00+00:00', + 'end': '2025-10-01T00:00:00+00:00', + 'price': 442.07, + 'start': '2025-09-30T23:45:00+00:00', }), dict({ - 'end': '2024-11-05T08:00:00+00:00', - 'price': 1346.54, - 'start': '2024-11-05T07:00:00+00:00', + 'end': '2025-10-01T00:15:00+00:00', + 'price': 504.08, + 'start': '2025-10-01T00:00:00+00:00', }), dict({ - 'end': '2024-11-05T09:00:00+00:00', - 'price': 1150.28, - 'start': '2024-11-05T08:00:00+00:00', + 'end': '2025-10-01T00:30:00+00:00', + 'price': 504.85, + 'start': '2025-10-01T00:15:00+00:00', }), dict({ - 'end': '2024-11-05T10:00:00+00:00', - 'price': 1031.32, - 'start': '2024-11-05T09:00:00+00:00', + 'end': '2025-10-01T00:45:00+00:00', + 'price': 504.3, + 'start': '2025-10-01T00:30:00+00:00', }), dict({ - 'end': '2024-11-05T11:00:00+00:00', - 'price': 927.37, - 'start': '2024-11-05T10:00:00+00:00', + 'end': '2025-10-01T01:00:00+00:00', + 'price': 506.29, + 'start': '2025-10-01T00:45:00+00:00', }), dict({ - 'end': '2024-11-05T12:00:00+00:00', - 'price': 925.05, - 'start': '2024-11-05T11:00:00+00:00', + 'end': '2025-10-01T01:15:00+00:00', + 'price': 442.07, + 'start': '2025-10-01T01:00:00+00:00', }), dict({ - 'end': '2024-11-05T13:00:00+00:00', - 'price': 949.49, - 'start': '2024-11-05T12:00:00+00:00', + 'end': '2025-10-01T01:30:00+00:00', + 'price': 441.96, + 'start': '2025-10-01T01:15:00+00:00', }), dict({ - 'end': '2024-11-05T14:00:00+00:00', - 'price': 1042.03, - 'start': '2024-11-05T13:00:00+00:00', + 'end': '2025-10-01T01:45:00+00:00', + 'price': 442.07, + 'start': '2025-10-01T01:30:00+00:00', }), dict({ - 'end': '2024-11-05T15:00:00+00:00', - 'price': 1258.89, - 'start': '2024-11-05T14:00:00+00:00', + 'end': '2025-10-01T02:00:00+00:00', + 'price': 442.07, + 'start': '2025-10-01T01:45:00+00:00', }), dict({ - 'end': '2024-11-05T16:00:00+00:00', - 'price': 1816.45, - 'start': '2024-11-05T15:00:00+00:00', + 'end': '2025-10-01T02:15:00+00:00', + 'price': 441.96, + 'start': '2025-10-01T02:00:00+00:00', }), dict({ - 'end': '2024-11-05T17:00:00+00:00', - 'price': 2512.65, - 'start': '2024-11-05T16:00:00+00:00', + 'end': '2025-10-01T02:30:00+00:00', + 'price': 483.3, + 'start': '2025-10-01T02:15:00+00:00', }), dict({ - 'end': '2024-11-05T18:00:00+00:00', - 'price': 1819.83, - 'start': '2024-11-05T17:00:00+00:00', + 'end': '2025-10-01T02:45:00+00:00', + 'price': 484.29, + 'start': '2025-10-01T02:30:00+00:00', }), dict({ - 'end': '2024-11-05T19:00:00+00:00', - 'price': 1011.77, - 'start': '2024-11-05T18:00:00+00:00', + 'end': '2025-10-01T03:00:00+00:00', + 'price': 574.7, + 'start': '2025-10-01T02:45:00+00:00', }), dict({ - 'end': '2024-11-05T20:00:00+00:00', - 'price': 835.53, - 'start': '2024-11-05T19:00:00+00:00', + 'end': '2025-10-01T03:15:00+00:00', + 'price': 543.31, + 'start': '2025-10-01T03:00:00+00:00', }), dict({ - 'end': '2024-11-05T21:00:00+00:00', - 'price': 796.19, - 'start': '2024-11-05T20:00:00+00:00', + 'end': '2025-10-01T03:30:00+00:00', + 'price': 578.01, + 'start': '2025-10-01T03:15:00+00:00', }), dict({ - 'end': '2024-11-05T22:00:00+00:00', - 'price': 522.3, - 'start': '2024-11-05T21:00:00+00:00', + 'end': '2025-10-01T03:45:00+00:00', + 'price': 774.96, + 'start': '2025-10-01T03:30:00+00:00', }), dict({ - 'end': '2024-11-05T23:00:00+00:00', - 'price': 289.14, - 'start': '2024-11-05T22:00:00+00:00', + 'end': '2025-10-01T04:00:00+00:00', + 'price': 787.0, + 'start': '2025-10-01T03:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T04:15:00+00:00', + 'price': 902.38, + 'start': '2025-10-01T04:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T04:30:00+00:00', + 'price': 1079.32, + 'start': '2025-10-01T04:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T04:45:00+00:00', + 'price': 1222.67, + 'start': '2025-10-01T04:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T05:00:00+00:00', + 'price': 1394.63, + 'start': '2025-10-01T04:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T05:15:00+00:00', + 'price': 1529.36, + 'start': '2025-10-01T05:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T05:30:00+00:00', + 'price': 1724.53, + 'start': '2025-10-01T05:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T05:45:00+00:00', + 'price': 1809.96, + 'start': '2025-10-01T05:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T06:00:00+00:00', + 'price': 1713.04, + 'start': '2025-10-01T05:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T06:15:00+00:00', + 'price': 1925.9, + 'start': '2025-10-01T06:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T06:30:00+00:00', + 'price': 1440.06, + 'start': '2025-10-01T06:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T06:45:00+00:00', + 'price': 1183.32, + 'start': '2025-10-01T06:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T07:00:00+00:00', + 'price': 962.95, + 'start': '2025-10-01T06:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T07:15:00+00:00', + 'price': 1402.04, + 'start': '2025-10-01T07:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T07:30:00+00:00', + 'price': 1060.65, + 'start': '2025-10-01T07:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T07:45:00+00:00', + 'price': 949.13, + 'start': '2025-10-01T07:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T08:00:00+00:00', + 'price': 841.82, + 'start': '2025-10-01T07:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T08:15:00+00:00', + 'price': 1037.44, + 'start': '2025-10-01T08:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T08:30:00+00:00', + 'price': 950.13, + 'start': '2025-10-01T08:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T08:45:00+00:00', + 'price': 826.13, + 'start': '2025-10-01T08:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T09:00:00+00:00', + 'price': 684.55, + 'start': '2025-10-01T08:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T09:15:00+00:00', + 'price': 861.6, + 'start': '2025-10-01T09:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T09:30:00+00:00', + 'price': 722.79, + 'start': '2025-10-01T09:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T09:45:00+00:00', + 'price': 640.57, + 'start': '2025-10-01T09:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T10:00:00+00:00', + 'price': 607.74, + 'start': '2025-10-01T09:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T10:15:00+00:00', + 'price': 674.05, + 'start': '2025-10-01T10:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T10:30:00+00:00', + 'price': 638.58, + 'start': '2025-10-01T10:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T10:45:00+00:00', + 'price': 638.47, + 'start': '2025-10-01T10:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T11:00:00+00:00', + 'price': 634.82, + 'start': '2025-10-01T10:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T11:15:00+00:00', + 'price': 637.36, + 'start': '2025-10-01T11:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T11:30:00+00:00', + 'price': 660.68, + 'start': '2025-10-01T11:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T11:45:00+00:00', + 'price': 679.14, + 'start': '2025-10-01T11:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T12:00:00+00:00', + 'price': 694.61, + 'start': '2025-10-01T11:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T12:15:00+00:00', + 'price': 622.33, + 'start': '2025-10-01T12:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T12:30:00+00:00', + 'price': 685.44, + 'start': '2025-10-01T12:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T12:45:00+00:00', + 'price': 732.85, + 'start': '2025-10-01T12:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T13:00:00+00:00', + 'price': 801.92, + 'start': '2025-10-01T12:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T13:15:00+00:00', + 'price': 629.4, + 'start': '2025-10-01T13:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T13:30:00+00:00', + 'price': 729.53, + 'start': '2025-10-01T13:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T13:45:00+00:00', + 'price': 884.81, + 'start': '2025-10-01T13:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T14:00:00+00:00', + 'price': 984.94, + 'start': '2025-10-01T13:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T14:15:00+00:00', + 'price': 615.26, + 'start': '2025-10-01T14:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T14:30:00+00:00', + 'price': 902.94, + 'start': '2025-10-01T14:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T14:45:00+00:00', + 'price': 1043.85, + 'start': '2025-10-01T14:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T15:00:00+00:00', + 'price': 1075.12, + 'start': '2025-10-01T14:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T15:15:00+00:00', + 'price': 980.52, + 'start': '2025-10-01T15:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T15:30:00+00:00', + 'price': 1162.66, + 'start': '2025-10-01T15:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T15:45:00+00:00', + 'price': 1453.87, + 'start': '2025-10-01T15:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T16:00:00+00:00', + 'price': 1955.96, + 'start': '2025-10-01T15:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T16:15:00+00:00', + 'price': 1423.48, + 'start': '2025-10-01T16:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T16:30:00+00:00', + 'price': 1900.04, + 'start': '2025-10-01T16:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T16:45:00+00:00', + 'price': 2611.11, + 'start': '2025-10-01T16:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T17:00:00+00:00', + 'price': 3467.41, + 'start': '2025-10-01T16:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T17:15:00+00:00', + 'price': 3828.03, + 'start': '2025-10-01T17:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T17:30:00+00:00', + 'price': 3429.83, + 'start': '2025-10-01T17:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T17:45:00+00:00', + 'price': 2934.38, + 'start': '2025-10-01T17:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T18:00:00+00:00', + 'price': 2308.07, + 'start': '2025-10-01T17:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T18:15:00+00:00', + 'price': 1997.96, + 'start': '2025-10-01T18:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T18:30:00+00:00', + 'price': 1424.03, + 'start': '2025-10-01T18:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T18:45:00+00:00', + 'price': 1216.81, + 'start': '2025-10-01T18:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T19:00:00+00:00', + 'price': 1070.15, + 'start': '2025-10-01T18:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T19:15:00+00:00', + 'price': 1218.14, + 'start': '2025-10-01T19:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T19:30:00+00:00', + 'price': 1135.8, + 'start': '2025-10-01T19:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T19:45:00+00:00', + 'price': 959.96, + 'start': '2025-10-01T19:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T20:00:00+00:00', + 'price': 913.66, + 'start': '2025-10-01T19:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T20:15:00+00:00', + 'price': 1001.63, + 'start': '2025-10-01T20:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T20:30:00+00:00', + 'price': 933.0, + 'start': '2025-10-01T20:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T20:45:00+00:00', + 'price': 874.53, + 'start': '2025-10-01T20:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T21:00:00+00:00', + 'price': 821.71, + 'start': '2025-10-01T20:45:00+00:00', + }), + dict({ + 'end': '2025-10-01T21:15:00+00:00', + 'price': 860.5, + 'start': '2025-10-01T21:00:00+00:00', + }), + dict({ + 'end': '2025-10-01T21:30:00+00:00', + 'price': 840.16, + 'start': '2025-10-01T21:15:00+00:00', + }), + dict({ + 'end': '2025-10-01T21:45:00+00:00', + 'price': 820.05, + 'start': '2025-10-01T21:30:00+00:00', + }), + dict({ + 'end': '2025-10-01T22:00:00+00:00', + 'price': 785.68, + 'start': '2025-10-01T21:45:00+00:00', }), ]), }) @@ -135,484 +495,484 @@ dict({ 'SE3': list([ dict({ - 'end': '2025-07-05T22:15:00+00:00', - 'price': 43.57, - 'start': '2025-07-05T22:00:00+00:00', + 'end': '2025-09-30T22:15:00+00:00', + 'price': 556.68, + 'start': '2025-09-30T22:00:00+00:00', }), dict({ - 'end': '2025-07-05T22:30:00+00:00', - 'price': 43.57, - 'start': '2025-07-05T22:15:00+00:00', + 'end': '2025-09-30T22:30:00+00:00', + 'price': 519.88, + 'start': '2025-09-30T22:15:00+00:00', }), dict({ - 'end': '2025-07-05T22:45:00+00:00', - 'price': 43.57, - 'start': '2025-07-05T22:30:00+00:00', + 'end': '2025-09-30T22:45:00+00:00', + 'price': 508.28, + 'start': '2025-09-30T22:30:00+00:00', }), dict({ - 'end': '2025-07-05T23:00:00+00:00', - 'price': 43.57, - 'start': '2025-07-05T22:45:00+00:00', + 'end': '2025-09-30T23:00:00+00:00', + 'price': 509.93, + 'start': '2025-09-30T22:45:00+00:00', }), dict({ - 'end': '2025-07-05T23:15:00+00:00', - 'price': 36.47, - 'start': '2025-07-05T23:00:00+00:00', + 'end': '2025-09-30T23:15:00+00:00', + 'price': 501.64, + 'start': '2025-09-30T23:00:00+00:00', }), dict({ - 'end': '2025-07-05T23:30:00+00:00', - 'price': 36.47, - 'start': '2025-07-05T23:15:00+00:00', + 'end': '2025-09-30T23:30:00+00:00', + 'price': 509.05, + 'start': '2025-09-30T23:15:00+00:00', }), dict({ - 'end': '2025-07-05T23:45:00+00:00', - 'price': 36.47, - 'start': '2025-07-05T23:30:00+00:00', + 'end': '2025-09-30T23:45:00+00:00', + 'price': 491.03, + 'start': '2025-09-30T23:30:00+00:00', }), dict({ - 'end': '2025-07-06T00:00:00+00:00', - 'price': 36.47, - 'start': '2025-07-05T23:45:00+00:00', + 'end': '2025-10-01T00:00:00+00:00', + 'price': 442.07, + 'start': '2025-09-30T23:45:00+00:00', }), dict({ - 'end': '2025-07-06T00:15:00+00:00', - 'price': 35.57, - 'start': '2025-07-06T00:00:00+00:00', + 'end': '2025-10-01T00:15:00+00:00', + 'price': 504.08, + 'start': '2025-10-01T00:00:00+00:00', }), dict({ - 'end': '2025-07-06T00:30:00+00:00', - 'price': 35.57, - 'start': '2025-07-06T00:15:00+00:00', + 'end': '2025-10-01T00:30:00+00:00', + 'price': 504.85, + 'start': '2025-10-01T00:15:00+00:00', }), dict({ - 'end': '2025-07-06T00:45:00+00:00', - 'price': 35.57, - 'start': '2025-07-06T00:30:00+00:00', + 'end': '2025-10-01T00:45:00+00:00', + 'price': 504.3, + 'start': '2025-10-01T00:30:00+00:00', }), dict({ - 'end': '2025-07-06T01:00:00+00:00', - 'price': 35.57, - 'start': '2025-07-06T00:45:00+00:00', + 'end': '2025-10-01T01:00:00+00:00', + 'price': 506.29, + 'start': '2025-10-01T00:45:00+00:00', }), dict({ - 'end': '2025-07-06T01:15:00+00:00', - 'price': 30.73, - 'start': '2025-07-06T01:00:00+00:00', + 'end': '2025-10-01T01:15:00+00:00', + 'price': 442.07, + 'start': '2025-10-01T01:00:00+00:00', }), dict({ - 'end': '2025-07-06T01:30:00+00:00', - 'price': 30.73, - 'start': '2025-07-06T01:15:00+00:00', + 'end': '2025-10-01T01:30:00+00:00', + 'price': 441.96, + 'start': '2025-10-01T01:15:00+00:00', }), dict({ - 'end': '2025-07-06T01:45:00+00:00', - 'price': 30.73, - 'start': '2025-07-06T01:30:00+00:00', + 'end': '2025-10-01T01:45:00+00:00', + 'price': 442.07, + 'start': '2025-10-01T01:30:00+00:00', }), dict({ - 'end': '2025-07-06T02:00:00+00:00', - 'price': 30.73, - 'start': '2025-07-06T01:45:00+00:00', + 'end': '2025-10-01T02:00:00+00:00', + 'price': 442.07, + 'start': '2025-10-01T01:45:00+00:00', }), dict({ - 'end': '2025-07-06T02:15:00+00:00', - 'price': 32.42, - 'start': '2025-07-06T02:00:00+00:00', + 'end': '2025-10-01T02:15:00+00:00', + 'price': 441.96, + 'start': '2025-10-01T02:00:00+00:00', }), dict({ - 'end': '2025-07-06T02:30:00+00:00', - 'price': 32.42, - 'start': '2025-07-06T02:15:00+00:00', + 'end': '2025-10-01T02:30:00+00:00', + 'price': 483.3, + 'start': '2025-10-01T02:15:00+00:00', }), dict({ - 'end': '2025-07-06T02:45:00+00:00', - 'price': 32.42, - 'start': '2025-07-06T02:30:00+00:00', + 'end': '2025-10-01T02:45:00+00:00', + 'price': 484.29, + 'start': '2025-10-01T02:30:00+00:00', }), dict({ - 'end': '2025-07-06T03:00:00+00:00', - 'price': 32.42, - 'start': '2025-07-06T02:45:00+00:00', + 'end': '2025-10-01T03:00:00+00:00', + 'price': 574.7, + 'start': '2025-10-01T02:45:00+00:00', }), dict({ - 'end': '2025-07-06T03:15:00+00:00', - 'price': 38.73, - 'start': '2025-07-06T03:00:00+00:00', + 'end': '2025-10-01T03:15:00+00:00', + 'price': 543.31, + 'start': '2025-10-01T03:00:00+00:00', }), dict({ - 'end': '2025-07-06T03:30:00+00:00', - 'price': 38.73, - 'start': '2025-07-06T03:15:00+00:00', + 'end': '2025-10-01T03:30:00+00:00', + 'price': 578.01, + 'start': '2025-10-01T03:15:00+00:00', }), dict({ - 'end': '2025-07-06T03:45:00+00:00', - 'price': 38.73, - 'start': '2025-07-06T03:30:00+00:00', + 'end': '2025-10-01T03:45:00+00:00', + 'price': 774.96, + 'start': '2025-10-01T03:30:00+00:00', }), dict({ - 'end': '2025-07-06T04:00:00+00:00', - 'price': 38.73, - 'start': '2025-07-06T03:45:00+00:00', + 'end': '2025-10-01T04:00:00+00:00', + 'price': 787.0, + 'start': '2025-10-01T03:45:00+00:00', }), dict({ - 'end': '2025-07-06T04:15:00+00:00', - 'price': 42.78, - 'start': '2025-07-06T04:00:00+00:00', + 'end': '2025-10-01T04:15:00+00:00', + 'price': 902.38, + 'start': '2025-10-01T04:00:00+00:00', }), dict({ - 'end': '2025-07-06T04:30:00+00:00', - 'price': 42.78, - 'start': '2025-07-06T04:15:00+00:00', + 'end': '2025-10-01T04:30:00+00:00', + 'price': 1079.32, + 'start': '2025-10-01T04:15:00+00:00', }), dict({ - 'end': '2025-07-06T04:45:00+00:00', - 'price': 42.78, - 'start': '2025-07-06T04:30:00+00:00', + 'end': '2025-10-01T04:45:00+00:00', + 'price': 1222.67, + 'start': '2025-10-01T04:30:00+00:00', }), dict({ - 'end': '2025-07-06T05:00:00+00:00', - 'price': 42.78, - 'start': '2025-07-06T04:45:00+00:00', + 'end': '2025-10-01T05:00:00+00:00', + 'price': 1394.63, + 'start': '2025-10-01T04:45:00+00:00', }), dict({ - 'end': '2025-07-06T05:15:00+00:00', - 'price': 54.71, - 'start': '2025-07-06T05:00:00+00:00', + 'end': '2025-10-01T05:15:00+00:00', + 'price': 1529.36, + 'start': '2025-10-01T05:00:00+00:00', }), dict({ - 'end': '2025-07-06T05:30:00+00:00', - 'price': 54.71, - 'start': '2025-07-06T05:15:00+00:00', + 'end': '2025-10-01T05:30:00+00:00', + 'price': 1724.53, + 'start': '2025-10-01T05:15:00+00:00', }), dict({ - 'end': '2025-07-06T05:45:00+00:00', - 'price': 54.71, - 'start': '2025-07-06T05:30:00+00:00', + 'end': '2025-10-01T05:45:00+00:00', + 'price': 1809.96, + 'start': '2025-10-01T05:30:00+00:00', }), dict({ - 'end': '2025-07-06T06:00:00+00:00', - 'price': 54.71, - 'start': '2025-07-06T05:45:00+00:00', + 'end': '2025-10-01T06:00:00+00:00', + 'price': 1713.04, + 'start': '2025-10-01T05:45:00+00:00', }), dict({ - 'end': '2025-07-06T06:15:00+00:00', - 'price': 83.87, - 'start': '2025-07-06T06:00:00+00:00', + 'end': '2025-10-01T06:15:00+00:00', + 'price': 1925.9, + 'start': '2025-10-01T06:00:00+00:00', }), dict({ - 'end': '2025-07-06T06:30:00+00:00', - 'price': 83.87, - 'start': '2025-07-06T06:15:00+00:00', + 'end': '2025-10-01T06:30:00+00:00', + 'price': 1440.06, + 'start': '2025-10-01T06:15:00+00:00', }), dict({ - 'end': '2025-07-06T06:45:00+00:00', - 'price': 83.87, - 'start': '2025-07-06T06:30:00+00:00', + 'end': '2025-10-01T06:45:00+00:00', + 'price': 1183.32, + 'start': '2025-10-01T06:30:00+00:00', }), dict({ - 'end': '2025-07-06T07:00:00+00:00', - 'price': 83.87, - 'start': '2025-07-06T06:45:00+00:00', + 'end': '2025-10-01T07:00:00+00:00', + 'price': 962.95, + 'start': '2025-10-01T06:45:00+00:00', }), dict({ - 'end': '2025-07-06T07:15:00+00:00', - 'price': 78.8, - 'start': '2025-07-06T07:00:00+00:00', + 'end': '2025-10-01T07:15:00+00:00', + 'price': 1402.04, + 'start': '2025-10-01T07:00:00+00:00', }), dict({ - 'end': '2025-07-06T07:30:00+00:00', - 'price': 78.8, - 'start': '2025-07-06T07:15:00+00:00', + 'end': '2025-10-01T07:30:00+00:00', + 'price': 1060.65, + 'start': '2025-10-01T07:15:00+00:00', }), dict({ - 'end': '2025-07-06T07:45:00+00:00', - 'price': 78.8, - 'start': '2025-07-06T07:30:00+00:00', + 'end': '2025-10-01T07:45:00+00:00', + 'price': 949.13, + 'start': '2025-10-01T07:30:00+00:00', }), dict({ - 'end': '2025-07-06T08:00:00+00:00', - 'price': 78.8, - 'start': '2025-07-06T07:45:00+00:00', + 'end': '2025-10-01T08:00:00+00:00', + 'price': 841.82, + 'start': '2025-10-01T07:45:00+00:00', }), dict({ - 'end': '2025-07-06T08:15:00+00:00', - 'price': 92.09, - 'start': '2025-07-06T08:00:00+00:00', + 'end': '2025-10-01T08:15:00+00:00', + 'price': 1037.44, + 'start': '2025-10-01T08:00:00+00:00', }), dict({ - 'end': '2025-07-06T08:30:00+00:00', - 'price': 92.09, - 'start': '2025-07-06T08:15:00+00:00', + 'end': '2025-10-01T08:30:00+00:00', + 'price': 950.13, + 'start': '2025-10-01T08:15:00+00:00', }), dict({ - 'end': '2025-07-06T08:45:00+00:00', - 'price': 92.09, - 'start': '2025-07-06T08:30:00+00:00', + 'end': '2025-10-01T08:45:00+00:00', + 'price': 826.13, + 'start': '2025-10-01T08:30:00+00:00', }), dict({ - 'end': '2025-07-06T09:00:00+00:00', - 'price': 92.09, - 'start': '2025-07-06T08:45:00+00:00', + 'end': '2025-10-01T09:00:00+00:00', + 'price': 684.55, + 'start': '2025-10-01T08:45:00+00:00', }), dict({ - 'end': '2025-07-06T09:15:00+00:00', - 'price': 104.92, - 'start': '2025-07-06T09:00:00+00:00', + 'end': '2025-10-01T09:15:00+00:00', + 'price': 861.6, + 'start': '2025-10-01T09:00:00+00:00', }), dict({ - 'end': '2025-07-06T09:30:00+00:00', - 'price': 104.92, - 'start': '2025-07-06T09:15:00+00:00', + 'end': '2025-10-01T09:30:00+00:00', + 'price': 722.79, + 'start': '2025-10-01T09:15:00+00:00', }), dict({ - 'end': '2025-07-06T09:45:00+00:00', - 'price': 104.92, - 'start': '2025-07-06T09:30:00+00:00', + 'end': '2025-10-01T09:45:00+00:00', + 'price': 640.57, + 'start': '2025-10-01T09:30:00+00:00', }), dict({ - 'end': '2025-07-06T10:00:00+00:00', - 'price': 104.92, - 'start': '2025-07-06T09:45:00+00:00', + 'end': '2025-10-01T10:00:00+00:00', + 'price': 607.74, + 'start': '2025-10-01T09:45:00+00:00', }), dict({ - 'end': '2025-07-06T10:15:00+00:00', - 'price': 72.5, - 'start': '2025-07-06T10:00:00+00:00', + 'end': '2025-10-01T10:15:00+00:00', + 'price': 674.05, + 'start': '2025-10-01T10:00:00+00:00', }), dict({ - 'end': '2025-07-06T10:30:00+00:00', - 'price': 72.5, - 'start': '2025-07-06T10:15:00+00:00', + 'end': '2025-10-01T10:30:00+00:00', + 'price': 638.58, + 'start': '2025-10-01T10:15:00+00:00', }), dict({ - 'end': '2025-07-06T10:45:00+00:00', - 'price': 72.5, - 'start': '2025-07-06T10:30:00+00:00', + 'end': '2025-10-01T10:45:00+00:00', + 'price': 638.47, + 'start': '2025-10-01T10:30:00+00:00', }), dict({ - 'end': '2025-07-06T11:00:00+00:00', - 'price': 72.5, - 'start': '2025-07-06T10:45:00+00:00', + 'end': '2025-10-01T11:00:00+00:00', + 'price': 634.82, + 'start': '2025-10-01T10:45:00+00:00', }), dict({ - 'end': '2025-07-06T11:15:00+00:00', - 'price': 63.49, - 'start': '2025-07-06T11:00:00+00:00', + 'end': '2025-10-01T11:15:00+00:00', + 'price': 637.36, + 'start': '2025-10-01T11:00:00+00:00', }), dict({ - 'end': '2025-07-06T11:30:00+00:00', - 'price': 63.49, - 'start': '2025-07-06T11:15:00+00:00', + 'end': '2025-10-01T11:30:00+00:00', + 'price': 660.68, + 'start': '2025-10-01T11:15:00+00:00', }), dict({ - 'end': '2025-07-06T11:45:00+00:00', - 'price': 63.49, - 'start': '2025-07-06T11:30:00+00:00', + 'end': '2025-10-01T11:45:00+00:00', + 'price': 679.14, + 'start': '2025-10-01T11:30:00+00:00', }), dict({ - 'end': '2025-07-06T12:00:00+00:00', - 'price': 63.49, - 'start': '2025-07-06T11:45:00+00:00', + 'end': '2025-10-01T12:00:00+00:00', + 'price': 694.61, + 'start': '2025-10-01T11:45:00+00:00', }), dict({ - 'end': '2025-07-06T12:15:00+00:00', - 'price': 91.64, - 'start': '2025-07-06T12:00:00+00:00', + 'end': '2025-10-01T12:15:00+00:00', + 'price': 622.33, + 'start': '2025-10-01T12:00:00+00:00', }), dict({ - 'end': '2025-07-06T12:30:00+00:00', - 'price': 91.64, - 'start': '2025-07-06T12:15:00+00:00', + 'end': '2025-10-01T12:30:00+00:00', + 'price': 685.44, + 'start': '2025-10-01T12:15:00+00:00', }), dict({ - 'end': '2025-07-06T12:45:00+00:00', - 'price': 91.64, - 'start': '2025-07-06T12:30:00+00:00', + 'end': '2025-10-01T12:45:00+00:00', + 'price': 732.85, + 'start': '2025-10-01T12:30:00+00:00', }), dict({ - 'end': '2025-07-06T13:00:00+00:00', - 'price': 91.64, - 'start': '2025-07-06T12:45:00+00:00', + 'end': '2025-10-01T13:00:00+00:00', + 'price': 801.92, + 'start': '2025-10-01T12:45:00+00:00', }), dict({ - 'end': '2025-07-06T13:15:00+00:00', - 'price': 111.79, - 'start': '2025-07-06T13:00:00+00:00', + 'end': '2025-10-01T13:15:00+00:00', + 'price': 629.4, + 'start': '2025-10-01T13:00:00+00:00', }), dict({ - 'end': '2025-07-06T13:30:00+00:00', - 'price': 111.79, - 'start': '2025-07-06T13:15:00+00:00', + 'end': '2025-10-01T13:30:00+00:00', + 'price': 729.53, + 'start': '2025-10-01T13:15:00+00:00', }), dict({ - 'end': '2025-07-06T13:45:00+00:00', - 'price': 111.79, - 'start': '2025-07-06T13:30:00+00:00', + 'end': '2025-10-01T13:45:00+00:00', + 'price': 884.81, + 'start': '2025-10-01T13:30:00+00:00', }), dict({ - 'end': '2025-07-06T14:00:00+00:00', - 'price': 111.79, - 'start': '2025-07-06T13:45:00+00:00', + 'end': '2025-10-01T14:00:00+00:00', + 'price': 984.94, + 'start': '2025-10-01T13:45:00+00:00', }), dict({ - 'end': '2025-07-06T14:15:00+00:00', - 'price': 234.04, - 'start': '2025-07-06T14:00:00+00:00', + 'end': '2025-10-01T14:15:00+00:00', + 'price': 615.26, + 'start': '2025-10-01T14:00:00+00:00', }), dict({ - 'end': '2025-07-06T14:30:00+00:00', - 'price': 234.04, - 'start': '2025-07-06T14:15:00+00:00', + 'end': '2025-10-01T14:30:00+00:00', + 'price': 902.94, + 'start': '2025-10-01T14:15:00+00:00', }), dict({ - 'end': '2025-07-06T14:45:00+00:00', - 'price': 234.04, - 'start': '2025-07-06T14:30:00+00:00', + 'end': '2025-10-01T14:45:00+00:00', + 'price': 1043.85, + 'start': '2025-10-01T14:30:00+00:00', }), dict({ - 'end': '2025-07-06T15:00:00+00:00', - 'price': 234.04, - 'start': '2025-07-06T14:45:00+00:00', + 'end': '2025-10-01T15:00:00+00:00', + 'price': 1075.12, + 'start': '2025-10-01T14:45:00+00:00', }), dict({ - 'end': '2025-07-06T15:15:00+00:00', - 'price': 435.33, - 'start': '2025-07-06T15:00:00+00:00', + 'end': '2025-10-01T15:15:00+00:00', + 'price': 980.52, + 'start': '2025-10-01T15:00:00+00:00', }), dict({ - 'end': '2025-07-06T15:30:00+00:00', - 'price': 435.33, - 'start': '2025-07-06T15:15:00+00:00', + 'end': '2025-10-01T15:30:00+00:00', + 'price': 1162.66, + 'start': '2025-10-01T15:15:00+00:00', }), dict({ - 'end': '2025-07-06T15:45:00+00:00', - 'price': 435.33, - 'start': '2025-07-06T15:30:00+00:00', + 'end': '2025-10-01T15:45:00+00:00', + 'price': 1453.87, + 'start': '2025-10-01T15:30:00+00:00', }), dict({ - 'end': '2025-07-06T16:00:00+00:00', - 'price': 435.33, - 'start': '2025-07-06T15:45:00+00:00', + 'end': '2025-10-01T16:00:00+00:00', + 'price': 1955.96, + 'start': '2025-10-01T15:45:00+00:00', }), dict({ - 'end': '2025-07-06T16:15:00+00:00', - 'price': 431.84, - 'start': '2025-07-06T16:00:00+00:00', + 'end': '2025-10-01T16:15:00+00:00', + 'price': 1423.48, + 'start': '2025-10-01T16:00:00+00:00', }), dict({ - 'end': '2025-07-06T16:30:00+00:00', - 'price': 431.84, - 'start': '2025-07-06T16:15:00+00:00', + 'end': '2025-10-01T16:30:00+00:00', + 'price': 1900.04, + 'start': '2025-10-01T16:15:00+00:00', }), dict({ - 'end': '2025-07-06T16:45:00+00:00', - 'price': 431.84, - 'start': '2025-07-06T16:30:00+00:00', + 'end': '2025-10-01T16:45:00+00:00', + 'price': 2611.11, + 'start': '2025-10-01T16:30:00+00:00', }), dict({ - 'end': '2025-07-06T17:00:00+00:00', - 'price': 431.84, - 'start': '2025-07-06T16:45:00+00:00', + 'end': '2025-10-01T17:00:00+00:00', + 'price': 3467.41, + 'start': '2025-10-01T16:45:00+00:00', }), dict({ - 'end': '2025-07-06T17:15:00+00:00', - 'price': 423.73, - 'start': '2025-07-06T17:00:00+00:00', + 'end': '2025-10-01T17:15:00+00:00', + 'price': 3828.03, + 'start': '2025-10-01T17:00:00+00:00', }), dict({ - 'end': '2025-07-06T17:30:00+00:00', - 'price': 423.73, - 'start': '2025-07-06T17:15:00+00:00', + 'end': '2025-10-01T17:30:00+00:00', + 'price': 3429.83, + 'start': '2025-10-01T17:15:00+00:00', }), dict({ - 'end': '2025-07-06T17:45:00+00:00', - 'price': 423.73, - 'start': '2025-07-06T17:30:00+00:00', + 'end': '2025-10-01T17:45:00+00:00', + 'price': 2934.38, + 'start': '2025-10-01T17:30:00+00:00', }), dict({ - 'end': '2025-07-06T18:00:00+00:00', - 'price': 423.73, - 'start': '2025-07-06T17:45:00+00:00', + 'end': '2025-10-01T18:00:00+00:00', + 'price': 2308.07, + 'start': '2025-10-01T17:45:00+00:00', }), dict({ - 'end': '2025-07-06T18:15:00+00:00', - 'price': 437.92, - 'start': '2025-07-06T18:00:00+00:00', + 'end': '2025-10-01T18:15:00+00:00', + 'price': 1997.96, + 'start': '2025-10-01T18:00:00+00:00', }), dict({ - 'end': '2025-07-06T18:30:00+00:00', - 'price': 437.92, - 'start': '2025-07-06T18:15:00+00:00', + 'end': '2025-10-01T18:30:00+00:00', + 'price': 1424.03, + 'start': '2025-10-01T18:15:00+00:00', }), dict({ - 'end': '2025-07-06T18:45:00+00:00', - 'price': 437.92, - 'start': '2025-07-06T18:30:00+00:00', + 'end': '2025-10-01T18:45:00+00:00', + 'price': 1216.81, + 'start': '2025-10-01T18:30:00+00:00', }), dict({ - 'end': '2025-07-06T19:00:00+00:00', - 'price': 437.92, - 'start': '2025-07-06T18:45:00+00:00', + 'end': '2025-10-01T19:00:00+00:00', + 'price': 1070.15, + 'start': '2025-10-01T18:45:00+00:00', }), dict({ - 'end': '2025-07-06T19:15:00+00:00', - 'price': 416.42, - 'start': '2025-07-06T19:00:00+00:00', + 'end': '2025-10-01T19:15:00+00:00', + 'price': 1218.14, + 'start': '2025-10-01T19:00:00+00:00', }), dict({ - 'end': '2025-07-06T19:30:00+00:00', - 'price': 416.42, - 'start': '2025-07-06T19:15:00+00:00', + 'end': '2025-10-01T19:30:00+00:00', + 'price': 1135.8, + 'start': '2025-10-01T19:15:00+00:00', }), dict({ - 'end': '2025-07-06T19:45:00+00:00', - 'price': 416.42, - 'start': '2025-07-06T19:30:00+00:00', + 'end': '2025-10-01T19:45:00+00:00', + 'price': 959.96, + 'start': '2025-10-01T19:30:00+00:00', }), dict({ - 'end': '2025-07-06T20:00:00+00:00', - 'price': 416.42, - 'start': '2025-07-06T19:45:00+00:00', + 'end': '2025-10-01T20:00:00+00:00', + 'price': 913.66, + 'start': '2025-10-01T19:45:00+00:00', }), dict({ - 'end': '2025-07-06T20:15:00+00:00', - 'price': 414.39, - 'start': '2025-07-06T20:00:00+00:00', + 'end': '2025-10-01T20:15:00+00:00', + 'price': 1001.63, + 'start': '2025-10-01T20:00:00+00:00', }), dict({ - 'end': '2025-07-06T20:30:00+00:00', - 'price': 414.39, - 'start': '2025-07-06T20:15:00+00:00', + 'end': '2025-10-01T20:30:00+00:00', + 'price': 933.0, + 'start': '2025-10-01T20:15:00+00:00', }), dict({ - 'end': '2025-07-06T20:45:00+00:00', - 'price': 414.39, - 'start': '2025-07-06T20:30:00+00:00', + 'end': '2025-10-01T20:45:00+00:00', + 'price': 874.53, + 'start': '2025-10-01T20:30:00+00:00', }), dict({ - 'end': '2025-07-06T21:00:00+00:00', - 'price': 414.39, - 'start': '2025-07-06T20:45:00+00:00', + 'end': '2025-10-01T21:00:00+00:00', + 'price': 821.71, + 'start': '2025-10-01T20:45:00+00:00', }), dict({ - 'end': '2025-07-06T21:15:00+00:00', - 'price': 396.38, - 'start': '2025-07-06T21:00:00+00:00', + 'end': '2025-10-01T21:15:00+00:00', + 'price': 860.5, + 'start': '2025-10-01T21:00:00+00:00', }), dict({ - 'end': '2025-07-06T21:30:00+00:00', - 'price': 396.38, - 'start': '2025-07-06T21:15:00+00:00', + 'end': '2025-10-01T21:30:00+00:00', + 'price': 840.16, + 'start': '2025-10-01T21:15:00+00:00', }), dict({ - 'end': '2025-07-06T21:45:00+00:00', - 'price': 396.38, - 'start': '2025-07-06T21:30:00+00:00', + 'end': '2025-10-01T21:45:00+00:00', + 'price': 820.05, + 'start': '2025-10-01T21:30:00+00:00', }), dict({ - 'end': '2025-07-06T22:00:00+00:00', - 'price': 396.38, - 'start': '2025-07-06T21:45:00+00:00', + 'end': '2025-10-01T22:00:00+00:00', + 'price': 785.68, + 'start': '2025-10-01T21:45:00+00:00', }), ]), }) @@ -621,124 +981,124 @@ dict({ 'SE3': list([ dict({ - 'end': '2025-07-05T23:00:00+00:00', - 'price': 43.57, - 'start': '2025-07-05T22:00:00+00:00', + 'end': '2025-09-30T23:00:00+00:00', + 'price': 523.75, + 'start': '2025-09-30T22:00:00+00:00', }), dict({ - 'end': '2025-07-06T00:00:00+00:00', - 'price': 36.47, - 'start': '2025-07-05T23:00:00+00:00', + 'end': '2025-10-01T00:00:00+00:00', + 'price': 485.95, + 'start': '2025-09-30T23:00:00+00:00', }), dict({ - 'end': '2025-07-06T01:00:00+00:00', - 'price': 35.57, - 'start': '2025-07-06T00:00:00+00:00', + 'end': '2025-10-01T01:00:00+00:00', + 'price': 504.85, + 'start': '2025-10-01T00:00:00+00:00', }), dict({ - 'end': '2025-07-06T02:00:00+00:00', - 'price': 30.73, - 'start': '2025-07-06T01:00:00+00:00', + 'end': '2025-10-01T02:00:00+00:00', + 'price': 442.07, + 'start': '2025-10-01T01:00:00+00:00', }), dict({ - 'end': '2025-07-06T03:00:00+00:00', - 'price': 32.42, - 'start': '2025-07-06T02:00:00+00:00', + 'end': '2025-10-01T03:00:00+00:00', + 'price': 496.12, + 'start': '2025-10-01T02:00:00+00:00', }), dict({ - 'end': '2025-07-06T04:00:00+00:00', - 'price': 38.73, - 'start': '2025-07-06T03:00:00+00:00', + 'end': '2025-10-01T04:00:00+00:00', + 'price': 670.85, + 'start': '2025-10-01T03:00:00+00:00', }), dict({ - 'end': '2025-07-06T05:00:00+00:00', - 'price': 42.78, - 'start': '2025-07-06T04:00:00+00:00', + 'end': '2025-10-01T05:00:00+00:00', + 'price': 1149.72, + 'start': '2025-10-01T04:00:00+00:00', }), dict({ - 'end': '2025-07-06T06:00:00+00:00', - 'price': 54.71, - 'start': '2025-07-06T05:00:00+00:00', + 'end': '2025-10-01T06:00:00+00:00', + 'price': 1694.25, + 'start': '2025-10-01T05:00:00+00:00', }), dict({ - 'end': '2025-07-06T07:00:00+00:00', - 'price': 83.87, - 'start': '2025-07-06T06:00:00+00:00', + 'end': '2025-10-01T07:00:00+00:00', + 'price': 1378.06, + 'start': '2025-10-01T06:00:00+00:00', }), dict({ - 'end': '2025-07-06T08:00:00+00:00', - 'price': 78.8, - 'start': '2025-07-06T07:00:00+00:00', + 'end': '2025-10-01T08:00:00+00:00', + 'price': 1063.41, + 'start': '2025-10-01T07:00:00+00:00', }), dict({ - 'end': '2025-07-06T09:00:00+00:00', - 'price': 92.09, - 'start': '2025-07-06T08:00:00+00:00', + 'end': '2025-10-01T09:00:00+00:00', + 'price': 874.53, + 'start': '2025-10-01T08:00:00+00:00', }), dict({ - 'end': '2025-07-06T10:00:00+00:00', - 'price': 104.92, - 'start': '2025-07-06T09:00:00+00:00', + 'end': '2025-10-01T10:00:00+00:00', + 'price': 708.2, + 'start': '2025-10-01T09:00:00+00:00', }), dict({ - 'end': '2025-07-06T11:00:00+00:00', - 'price': 72.5, - 'start': '2025-07-06T10:00:00+00:00', + 'end': '2025-10-01T11:00:00+00:00', + 'price': 646.53, + 'start': '2025-10-01T10:00:00+00:00', }), dict({ - 'end': '2025-07-06T12:00:00+00:00', - 'price': 63.49, - 'start': '2025-07-06T11:00:00+00:00', + 'end': '2025-10-01T12:00:00+00:00', + 'price': 667.97, + 'start': '2025-10-01T11:00:00+00:00', }), dict({ - 'end': '2025-07-06T13:00:00+00:00', - 'price': 91.64, - 'start': '2025-07-06T12:00:00+00:00', + 'end': '2025-10-01T13:00:00+00:00', + 'price': 710.63, + 'start': '2025-10-01T12:00:00+00:00', }), dict({ - 'end': '2025-07-06T14:00:00+00:00', - 'price': 111.79, - 'start': '2025-07-06T13:00:00+00:00', + 'end': '2025-10-01T14:00:00+00:00', + 'price': 807.23, + 'start': '2025-10-01T13:00:00+00:00', }), dict({ - 'end': '2025-07-06T15:00:00+00:00', - 'price': 234.04, - 'start': '2025-07-06T14:00:00+00:00', + 'end': '2025-10-01T15:00:00+00:00', + 'price': 909.35, + 'start': '2025-10-01T14:00:00+00:00', }), dict({ - 'end': '2025-07-06T16:00:00+00:00', - 'price': 435.33, - 'start': '2025-07-06T15:00:00+00:00', + 'end': '2025-10-01T16:00:00+00:00', + 'price': 1388.22, + 'start': '2025-10-01T15:00:00+00:00', }), dict({ - 'end': '2025-07-06T17:00:00+00:00', - 'price': 431.84, - 'start': '2025-07-06T16:00:00+00:00', + 'end': '2025-10-01T17:00:00+00:00', + 'price': 2350.51, + 'start': '2025-10-01T16:00:00+00:00', }), dict({ - 'end': '2025-07-06T18:00:00+00:00', - 'price': 423.73, - 'start': '2025-07-06T17:00:00+00:00', + 'end': '2025-10-01T18:00:00+00:00', + 'price': 3125.13, + 'start': '2025-10-01T17:00:00+00:00', }), dict({ - 'end': '2025-07-06T19:00:00+00:00', - 'price': 437.92, - 'start': '2025-07-06T18:00:00+00:00', + 'end': '2025-10-01T19:00:00+00:00', + 'price': 1427.24, + 'start': '2025-10-01T18:00:00+00:00', }), dict({ - 'end': '2025-07-06T20:00:00+00:00', - 'price': 416.42, - 'start': '2025-07-06T19:00:00+00:00', + 'end': '2025-10-01T20:00:00+00:00', + 'price': 1056.89, + 'start': '2025-10-01T19:00:00+00:00', }), dict({ - 'end': '2025-07-06T21:00:00+00:00', - 'price': 414.39, - 'start': '2025-07-06T20:00:00+00:00', + 'end': '2025-10-01T21:00:00+00:00', + 'price': 907.69, + 'start': '2025-10-01T20:00:00+00:00', }), dict({ - 'end': '2025-07-06T22:00:00+00:00', - 'price': 396.38, - 'start': '2025-07-06T21:00:00+00:00', + 'end': '2025-10-01T22:00:00+00:00', + 'price': 826.57, + 'start': '2025-10-01T21:00:00+00:00', }), ]), }) diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py index 1f0e99b65ff..9756c909cf3 100644 --- a/tests/components/nordpool/test_config_flow.py +++ b/tests/components/nordpool/test_config_flow.py @@ -26,7 +26,7 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_form(hass: HomeAssistant, get_client: NordPoolClient) -> None: """Test we get the form.""" @@ -48,7 +48,7 @@ async def test_form(hass: HomeAssistant, get_client: NordPoolClient) -> None: assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_single_config_entry( hass: HomeAssistant, load_int: None, get_client: NordPoolClient ) -> None: @@ -61,7 +61,7 @@ async def test_single_config_entry( assert result["reason"] == "single_instance_allowed" -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") @pytest.mark.parametrize( ("error_message", "p_error"), [ @@ -107,7 +107,7 @@ async def test_cannot_connect( assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_reconfigure( hass: HomeAssistant, load_int: MockConfigEntry, @@ -134,7 +134,7 @@ async def test_reconfigure( } -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") @pytest.mark.parametrize( ("error_message", "p_error"), [ diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index c2d18c4702a..94d66a789cc 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -16,17 +16,22 @@ from pynordpool import ( ) import pytest +from homeassistant.components.homeassistant import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.nordpool.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from . import ENTRY_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T10:00:00+00:00") async def test_coordinator( hass: HomeAssistant, get_client: NordPoolClient, @@ -34,18 +39,37 @@ async def test_coordinator( caplog: pytest.LogCaptureFixture, ) -> None: """Test the Nord Pool coordinator with errors.""" + await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) config_entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.92737" + assert state.state == "0.67405" + + assert "Next data update at 2025-10-01 11:00:00+00:00" in caplog.text + assert "Next listener update at 2025-10-01 10:15:00+00:00" in caplog.text + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + wraps=get_client.async_get_delivery_period, + ) as mock_data, + ): + freezer.tick(timedelta(minutes=17)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 0 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "0.63858" + + assert "Next data update at 2025-10-01 11:00:00+00:00" in caplog.text + assert "Next listener update at 2025-10-01 10:30:00+00:00" in caplog.text with ( patch( @@ -58,7 +82,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "0.66068" with ( patch( @@ -72,7 +96,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "0.68544" assert "Authentication error" in caplog.text with ( @@ -88,7 +112,7 @@ async def test_coordinator( # Empty responses does not raise assert mock_data.call_count == 3 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "0.72953" assert "Empty response" in caplog.text with ( @@ -103,7 +127,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "0.90294" assert "error" in caplog.text with ( @@ -118,7 +142,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "1.16266" assert "error" in caplog.text with ( @@ -133,11 +157,69 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE + assert state.state == "1.90004" assert "Response error" in caplog.text freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.81983" + assert state.state == "3.42983" + + # Test manual polling + hass.config_entries.async_update_entry( + entry=config_entry, pref_disable_polling=True + ) + await hass.config_entries.async_reload(config_entry.entry_id) + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "1.42403" + + # Prices should update without any polling made (read from cache) + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "1.1358" + + # Test manually updating the data + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_periods", + wraps=get_client.async_get_delivery_periods, + ) as mock_data, + ): + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "sensor.nord_pool_se3_current_price"}, + blocking=True, + ) + assert mock_data.call_count == 1 + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "0.933" + + hass.config_entries.async_update_entry( + entry=config_entry, pref_disable_polling=False + ) + await hass.config_entries.async_reload(config_entry.entry_id) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=NordPoolError("error"), + ) as mock_data, + ): + freezer.tick(timedelta(hours=48)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "Data for current day is missing" in caplog.text diff --git a/tests/components/nordpool/test_diagnostics.py b/tests/components/nordpool/test_diagnostics.py index a9dfdd5eca5..d38e921afbb 100644 --- a/tests/components/nordpool/test_diagnostics.py +++ b/tests/components/nordpool/test_diagnostics.py @@ -12,7 +12,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T10:00:00+00:00") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/nordpool/test_init.py b/tests/components/nordpool/test_init.py index 48ddc59d083..e2b16d37bd6 100644 --- a/tests/components/nordpool/test_init.py +++ b/tests/components/nordpool/test_init.py @@ -28,7 +28,7 @@ from tests.common import MockConfigEntry, async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T10:00:00+00:00") async def test_unload_entry(hass: HomeAssistant, get_client: NordPoolClient) -> None: """Test load and unload an entry.""" entry = MockConfigEntry( @@ -79,7 +79,7 @@ async def test_initial_startup_fails( assert entry.state is ConfigEntryState.SETUP_RETRY -@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T10:00:00+00:00") async def test_reconfigure_cleans_up_device( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -115,7 +115,7 @@ async def test_reconfigure_cleans_up_device( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-04", + "date": "2025-09-30", "market": "DayAhead", "deliveryArea": "NL", "currency": "EUR", @@ -126,7 +126,7 @@ async def test_reconfigure_cleans_up_device( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-05", + "date": "2025-10-01", "market": "DayAhead", "deliveryArea": "NL", "currency": "EUR", @@ -137,7 +137,7 @@ async def test_reconfigure_cleans_up_device( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-06", + "date": "2025-10-02", "market": "DayAhead", "deliveryArea": "NL", "currency": "EUR", diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index 082684a2a02..cedcb57c95e 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -20,7 +20,7 @@ from tests.common import async_fire_time_changed, snapshot_platform from tests.test_util.aiohttp import AiohttpClientMocker -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, @@ -33,7 +33,7 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_current_price_is_0( hass: HomeAssistant, load_int: ConfigEntry @@ -43,10 +43,10 @@ async def test_sensor_current_price_is_0( current_price = hass.states.get("sensor.nord_pool_se4_current_price") assert current_price is not None - assert current_price.state == "0.0" # SE4 2024-11-05T18:00:00Z + assert current_price.state == "0.0" # SE4 2025-10-01T18:00:00Z -@pytest.mark.freeze_time("2024-11-05T23:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T21:45:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_no_next_price(hass: HomeAssistant, load_int: ConfigEntry) -> None: """Test the Nord Pool sensor.""" @@ -58,12 +58,12 @@ async def test_sensor_no_next_price(hass: HomeAssistant, load_int: ConfigEntry) assert current_price is not None assert last_price is not None assert next_price is not None - assert current_price.state == "0.12666" # SE3 2024-11-05T23:00:00Z - assert last_price.state == "0.28914" # SE3 2024-11-05T22:00:00Z - assert next_price.state == "0.07406" # SE3 2024-11-06T00:00:00Z" + assert current_price.state == "0.78568" # SE3 2025-10-01T21:45:00Z + assert last_price.state == "0.82171" # SE3 2025-10-01T21:30:00Z + assert next_price.state == "0.81174" # SE3 2025-10-01T22:00:00Z -@pytest.mark.freeze_time("2024-11-06T00:00:00+01:00") +@pytest.mark.freeze_time("2025-10-02T00:00:00+02:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_no_previous_price( hass: HomeAssistant, load_int: ConfigEntry @@ -77,12 +77,12 @@ async def test_sensor_no_previous_price( assert current_price is not None assert last_price is not None assert next_price is not None - assert current_price.state == "0.12666" # SE3 2024-11-05T23:00:00Z - assert last_price.state == "0.28914" # SE3 2024-11-05T22:00:00Z - assert next_price.state == "0.07406" # SE3 2024-11-06T00:00:00Z + assert current_price.state == "0.93322" # SE3 2025-10-01T22:00:00Z + assert last_price.state == "0.8605" # SE3 2025-10-01T21:45:00Z + assert next_price.state == "0.83513" # SE3 2025-10-01T22:15:00Z -@pytest.mark.freeze_time("2024-11-05T11:00:01+01:00") +@pytest.mark.freeze_time("2025-10-01T11:00:01+01:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_empty_response( hass: HomeAssistant, @@ -101,16 +101,16 @@ async def test_sensor_empty_response( assert current_price is not None assert last_price is not None assert next_price is not None - assert current_price.state == "0.92737" - assert last_price.state == "1.03132" - assert next_price.state == "0.92505" + assert current_price.state == "0.67405" + assert last_price.state == "0.8616" + assert next_price.state == "0.63736" aioclient_mock.clear_requests() aioclient_mock.request( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-04", + "date": "2025-09-30", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", @@ -121,7 +121,7 @@ async def test_sensor_empty_response( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-05", + "date": "2025-10-01", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", @@ -133,7 +133,7 @@ async def test_sensor_empty_response( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-06", + "date": "2025-10-02", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", @@ -153,16 +153,16 @@ async def test_sensor_empty_response( assert current_price is not None assert last_price is not None assert next_price is not None - assert current_price.state == "0.92505" - assert last_price.state == "0.92737" - assert next_price.state == "0.94949" + assert current_price.state == "0.63736" + assert last_price.state == "0.67405" + assert next_price.state == "0.62233" aioclient_mock.clear_requests() aioclient_mock.request( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-04", + "date": "2025-09-30", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", @@ -173,7 +173,7 @@ async def test_sensor_empty_response( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-05", + "date": "2025-10-01", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", @@ -185,7 +185,7 @@ async def test_sensor_empty_response( "GET", url=API + "/DayAheadPrices", params={ - "date": "2024-11-06", + "date": "2025-10-02", "market": "DayAhead", "deliveryArea": "SE3,SE4", "currency": "SEK", @@ -193,7 +193,7 @@ async def test_sensor_empty_response( status=HTTPStatus.NO_CONTENT, ) - freezer.move_to("2024-11-05T22:00:01+00:00") + freezer.move_to("2025-10-01T21:45:01+00:00") async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) @@ -206,6 +206,6 @@ async def test_sensor_empty_response( assert current_price is not None assert last_price is not None assert next_price is not None - assert current_price.state == "0.28914" - assert last_price.state == "0.5223" + assert current_price.state == "0.78568" + assert last_price.state == "0.82171" assert next_price.state == STATE_UNKNOWN diff --git a/tests/components/nordpool/test_services.py b/tests/components/nordpool/test_services.py index 1042783fee8..9d940af4ad7 100644 --- a/tests/components/nordpool/test_services.py +++ b/tests/components/nordpool/test_services.py @@ -30,31 +30,31 @@ from tests.test_util.aiohttp import AiohttpClientMocker TEST_SERVICE_DATA = { ATTR_CONFIG_ENTRY: "to_replace", - ATTR_DATE: "2024-11-05", + ATTR_DATE: "2025-10-01", ATTR_AREAS: "SE3", ATTR_CURRENCY: "EUR", } TEST_SERVICE_DATA_USE_DEFAULTS = { ATTR_CONFIG_ENTRY: "to_replace", - ATTR_DATE: "2024-11-05", + ATTR_DATE: "2025-10-01", } TEST_SERVICE_INDICES_DATA_60 = { ATTR_CONFIG_ENTRY: "to_replace", - ATTR_DATE: "2025-07-06", + ATTR_DATE: "2025-10-01", ATTR_AREAS: "SE3", ATTR_CURRENCY: "SEK", ATTR_RESOLUTION: 60, } TEST_SERVICE_INDICES_DATA_15 = { ATTR_CONFIG_ENTRY: "to_replace", - ATTR_DATE: "2025-07-06", + ATTR_DATE: "2025-10-01", ATTR_AREAS: "SE3", ATTR_CURRENCY: "SEK", ATTR_RESOLUTION: 15, } -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_service_call( hass: HomeAssistant, load_int: MockConfigEntry, @@ -96,7 +96,7 @@ async def test_service_call( (NordPoolError, "connection_error"), ], ) -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_service_call_failures( hass: HomeAssistant, load_int: MockConfigEntry, @@ -124,7 +124,7 @@ async def test_service_call_failures( assert err.value.translation_key == key -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_empty_response_returns_empty_list( hass: HomeAssistant, load_int: MockConfigEntry, @@ -151,7 +151,7 @@ async def test_empty_response_returns_empty_list( assert response == snapshot -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_service_call_config_entry_bad_state( hass: HomeAssistant, load_int: MockConfigEntry, @@ -184,7 +184,7 @@ async def test_service_call_config_entry_bad_state( assert err.value.translation_key == "entry_not_loaded" -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_service_call_for_price_indices( hass: HomeAssistant, load_int: MockConfigEntry, @@ -200,7 +200,7 @@ async def test_service_call_for_price_indices( "GET", url=API + "/DayAheadPriceIndices", params={ - "date": "2025-07-06", + "date": "2025-10-01", "market": "DayAhead", "indexNames": "SE3", "currency": "SEK", @@ -213,7 +213,7 @@ async def test_service_call_for_price_indices( "GET", url=API + "/DayAheadPriceIndices", params={ - "date": "2025-07-06", + "date": "2025-10-01", "market": "DayAhead", "indexNames": "SE3", "currency": "SEK", diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py index d9bc620b464..91e2e1ee5f8 100644 --- a/tests/components/ntfy/conftest.py +++ b/tests/components/ntfy/conftest.py @@ -1,10 +1,11 @@ """Common fixtures for the ntfy tests.""" -from collections.abc import Generator -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +from collections.abc import Callable, Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, Mock, patch -from aiontfy import Account, AccountTokenResponse +from aiontfy import Account, AccountTokenResponse, Event, Notification import pytest from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN @@ -40,6 +41,50 @@ def mock_aiontfy() -> Generator[AsyncMock]: client.generate_token.return_value = AccountTokenResponse( token="token", last_access=datetime.now() ) + + resp = Mock( + id="h6Y2hKA5sy0U", + time=datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + expires=datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + event=Event.MESSAGE, + topic="mytopic", + message="Hello", + title="Title", + tags=["octopus"], + priority=3, + click="https://example.com/", + icon="https://example.com/icon.png", + actions=[], + attachment=None, + content_type=None, + ) + + resp.to_dict.return_value = { + "id": "h6Y2hKA5sy0U", + "time": datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + "expires": datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + "event": Event.MESSAGE, + "topic": "mytopic", + "message": "Hello", + "title": "Title", + "tags": ["octopus"], + "priority": 3, + "click": "https://example.com/", + "icon": "https://example.com/icon.png", + "actions": [], + "attachment": None, + "content_type": None, + } + + async def mock_ws( + topics: list[str], callback: Callable[[Notification], None], **kwargs + ): + callback(resp) + while True: + await asyncio.sleep(1) + + client.subscribe.side_effect = mock_ws + yield client diff --git a/tests/components/ntfy/snapshots/test_event.ambr b/tests/components/ntfy/snapshots/test_event.ambr new file mode 100644 index 00000000000..ed6095f0888 --- /dev/null +++ b/tests/components/ntfy/snapshots/test_event.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_event_platform[event.mytopic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'Title: Hello', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.mytopic', + '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': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'subscribe', + 'unique_id': '123456789_ABCDEF_subscribe', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_platform[event.mytopic-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'actions': list([ + ]), + 'attachment': None, + 'click': 'https://example.com/', + 'content_type': None, + 'entity_picture': 'https://example.com/icon.png', + 'event': , + 'event_type': 'Title: Hello', + 'event_types': list([ + 'Title: Hello', + ]), + 'expires': datetime.datetime(2025, 3, 29, 5, 58, 46, tzinfo=datetime.timezone.utc), + 'friendly_name': 'mytopic', + 'icon': 'https://example.com/icon.png', + 'id': 'h6Y2hKA5sy0U', + 'message': 'Hello', + 'priority': 3, + 'tags': list([ + 'octopus', + ]), + 'time': datetime.datetime(2025, 3, 28, 17, 58, 46, tzinfo=datetime.timezone.utc), + 'title': 'Title', + 'topic': 'mytopic', + }), + 'context': , + 'entity_id': 'event.mytopic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-09-03T22:00:00.000+00:00', + }) +# --- diff --git a/tests/components/ntfy/snapshots/test_sensor.ambr b/tests/components/ntfy/snapshots/test_sensor.ambr index fd0dd3c4bd4..b475b1ee0bc 100644 --- a/tests/components/ntfy/snapshots/test_sensor.ambr +++ b/tests/components/ntfy/snapshots/test_sensor.ambr @@ -190,7 +190,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , @@ -302,7 +302,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ 'suggested_unit_of_measurement': , diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 48909552e08..00118d28336 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -12,7 +12,17 @@ from aiontfy.exceptions import ( ) import pytest -from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN, SECTION_AUTH +from homeassistant import config_entries +from homeassistant.components.ntfy.const import ( + CONF_MESSAGE, + CONF_PRIORITY, + CONF_TAGS, + CONF_TITLE, + CONF_TOPIC, + DOMAIN, + SECTION_AUTH, + SECTION_FILTER, +) from homeassistant.config_entries import SOURCE_USER, ConfigSubentry from homeassistant.const import ( CONF_NAME, @@ -204,13 +214,27 @@ async def test_add_topic_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: { + CONF_PRIORITY: ["5"], + CONF_TAGS: ["octopus", "+1"], + CONF_TITLE: "title", + CONF_MESSAGE: "triggered", + }, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY subentry_id = list(config_entry.subentries)[0] assert config_entry.subentries == { subentry_id: ConfigSubentry( - data={CONF_TOPIC: "mytopic"}, + data={ + CONF_TOPIC: "mytopic", + CONF_PRIORITY: ["5"], + CONF_TAGS: ["octopus", "+1"], + CONF_TITLE: "title", + CONF_MESSAGE: "triggered", + }, subentry_id=subentry_id, subentry_type="topic", title="mytopic", @@ -252,21 +276,28 @@ async def test_generated_topic(hass: HomeAssistant, mock_random: AsyncMock) -> N result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: ""}, + user_input={ + CONF_TOPIC: "", + SECTION_FILTER: {}, + }, ) mock_random.assert_called_once() result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + user_input={ + CONF_TOPIC: "randomtopic", + CONF_NAME: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY subentry_id = list(config_entry.subentries)[0] assert config_entry.subentries == { subentry_id: ConfigSubentry( - data={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + data={CONF_TOPIC: "randomtopic"}, subentry_id=subentry_id, subentry_type="topic", title="mytopic", @@ -306,7 +337,10 @@ async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> Non result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "invalid,topic"}, + user_input={ + CONF_TOPIC: "invalid,topic", + SECTION_FILTER: {}, + }, ) assert result["type"] == FlowResultType.FORM @@ -314,7 +348,10 @@ async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> Non result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -360,7 +397,10 @@ async def test_topic_already_configured( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_TOPIC: "mytopic"}, + user_input={ + CONF_TOPIC: "mytopic", + SECTION_FILTER: {}, + }, ) assert result["type"] is FlowResultType.ABORT @@ -731,3 +771,65 @@ async def test_flow_reconfigure_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "account_mismatch" + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_topic_reconfigure_flow(hass: HomeAssistant) -> None: + """Test topic subentry reconfigure flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_USERNAME: None}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={ + CONF_TOPIC: "mytopic", + CONF_PRIORITY: ["1"], + CONF_TAGS: ["owl", "-1"], + CONF_TITLE: "", + CONF_MESSAGE: "triggered", + }, + subentry_id="subentry_id", + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + ], + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await config_entry.start_subentry_reconfigure_flow(hass, "subentry_id") + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + CONF_PRIORITY: ["5"], + CONF_TAGS: ["octopus", "+1"], + CONF_TITLE: "title", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.subentries == { + "subentry_id": ConfigSubentry( + data={ + CONF_TOPIC: "mytopic", + CONF_PRIORITY: ["5"], + CONF_TAGS: ["octopus", "+1"], + CONF_TITLE: "title", + CONF_MESSAGE: None, + }, + subentry_id="subentry_id", + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + await hass.async_block_till_done() diff --git a/tests/components/ntfy/test_event.py b/tests/components/ntfy/test_event.py new file mode 100644 index 00000000000..a71f45375d9 --- /dev/null +++ b/tests/components/ntfy/test_event.py @@ -0,0 +1,211 @@ +"""Tests for the ntfy event platform.""" + +import asyncio +from collections.abc import AsyncGenerator +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, patch + +from aiontfy import Event +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyForbiddenError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) +from freezegun.api import FrozenDateTimeFactory, freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ntfy.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +async def event_only() -> AsyncGenerator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.EVENT], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy") +@freeze_time("2025-09-03T22:00:00.000Z") +async def test_event_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the ntfy event platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_event( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test ntfy events.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("event.mytopic")) + assert state.state != STATE_UNKNOWN + + assert state.attributes == { + "actions": [], + "attachment": None, + "click": "https://example.com/", + "content_type": None, + "entity_picture": "https://example.com/icon.png", + "event": Event.MESSAGE, + "event_type": "Title: Hello", + "event_types": [ + "Title: Hello", + ], + "expires": datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC), + "friendly_name": "mytopic", + "icon": "https://example.com/icon.png", + "id": "h6Y2hKA5sy0U", + "message": "Hello", + "priority": 3, + "tags": [ + "octopus", + ], + "time": datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC), + "title": "Title", + "topic": "mytopic", + } + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + STATE_UNAVAILABLE, + ), + ( + NtfyConnectionError, + STATE_UNAVAILABLE, + ), + ( + NtfyTimeoutError, + STATE_UNAVAILABLE, + ), + ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + STATE_UNAVAILABLE, + ), + ( + NtfyForbiddenError(403, 403, "forbidden"), + STATE_UNAVAILABLE, + ), + ( + asyncio.CancelledError, + STATE_UNAVAILABLE, + ), + ( + asyncio.InvalidStateError, + STATE_UNKNOWN, + ), + ( + ValueError, + STATE_UNAVAILABLE, + ), + ], +) +async def test_event_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + freezer: FrozenDateTimeFactory, + exception: Exception, + expected_state: str, +) -> None: + """Test ntfy events exceptions.""" + mock_aiontfy.subscribe.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("event.mytopic")) + assert state.state == expected_state + + +async def test_event_topic_protected( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test ntfy events cannot subscribe to protected topic.""" + mock_aiontfy.subscribe.side_effect = NtfyForbiddenError(403, 403, "forbidden") + + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "repairs", {}) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("event.mytopic")) + assert state.state == STATE_UNAVAILABLE + + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id="topic_protected_mytopic" + ) + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, "topic_protected_mytopic") + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + + assert (entity := entity_registry.async_get("event.mytopic")) + assert entity.disabled + assert entity.disabled_by is er.RegistryEntryDisabler.USER diff --git a/tests/components/ntfy/test_services.py b/tests/components/ntfy/test_services.py new file mode 100644 index 00000000000..d07df40264f --- /dev/null +++ b/tests/components/ntfy/test_services.py @@ -0,0 +1,209 @@ +"""Tests for the ntfy notify platform.""" + +from typing import Any + +from aiontfy import Message +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import pytest +from yarl import URL + +from homeassistant.components.notify import ATTR_MESSAGE, ATTR_TITLE +from homeassistant.components.ntfy.const import DOMAIN +from homeassistant.components.ntfy.notify import ( + ATTR_ATTACH, + ATTR_CALL, + ATTR_CLICK, + ATTR_DELAY, + ATTR_EMAIL, + ATTR_ICON, + ATTR_MARKDOWN, + ATTR_PRIORITY, + ATTR_TAGS, + SERVICE_PUBLISH, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from tests.common import AsyncMock, MockConfigEntry + + +async def test_ntfy_publish( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test publishing ntfy message via ntfy.publish action.""" + + config_entry.add_to_hass(hass) + 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.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "Hello", + ATTR_TITLE: "World", + ATTR_ATTACH: "https://example.org/download.zip", + ATTR_CLICK: "https://example.org", + ATTR_DELAY: {"days": 1, "seconds": 30}, + ATTR_ICON: "https://example.org/logo.png", + ATTR_MARKDOWN: True, + ATTR_PRIORITY: "5", + ATTR_TAGS: ["partying_face", "grin"], + }, + blocking=True, + ) + + mock_aiontfy.publish.assert_called_once_with( + Message( + topic="mytopic", + message="Hello", + title="World", + tags=["partying_face", "grin"], + priority=5, + click=URL("https://example.org"), + attach=URL("https://example.org/download.zip"), + markdown=True, + icon=URL("https://example.org/logo.png"), + delay="86430.0s", + ) + ) + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + "Failed to publish notification: I'm a teapot", + ), + ( + NtfyException, + "Failed to publish notification due to a connection error", + ), + ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + "Failed to authenticate with ntfy service. Please verify your credentials", + ), + ], +) +async def test_send_message_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test publish message exceptions.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_aiontfy.publish.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) + + +@pytest.mark.parametrize( + ("payload", "error_msg"), + [ + ( + {ATTR_DELAY: {"days": 1, "seconds": 30}, ATTR_CALL: "1234567890"}, + "Delayed call notifications are not supported", + ), + ( + {ATTR_DELAY: {"days": 1, "seconds": 30}, ATTR_EMAIL: "mail@example.org"}, + "Delayed email notifications are not supported", + ), + ], +) +async def test_send_message_validation_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + payload: dict[str, Any], + error_msg: str, +) -> None: + """Test publish message service validation errors.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(ServiceValidationError, match=error_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + {ATTR_ENTITY_ID: "notify.mytopic", **payload}, + blocking=True, + ) + + +async def test_send_message_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test unauthorized exception initiates reauth flow.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_aiontfy.publish.side_effect = ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_PUBLISH, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + 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") == config_entry.entry_id diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 4904829a31c..4e5ddf286ba 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -145,6 +145,70 @@ async def test_chat_stream( assert result.response.speech["plain"]["speech"] == "test response" +async def test_thinking_content( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test that thinking content is retained in multi-turn conversation.""" + + entry = MockConfigEntry() + entry.add_to_hass(hass) + + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={ + **subentry.data, + ollama.CONF_THINK: True, + }, + ) + + conversation_id = "conversation_id_1234" + + with patch( + "ollama.AsyncClient.chat", + return_value=stream_generator( + { + "message": { + "role": "assistant", + "content": "test response", + "thinking": "test thinking", + }, + "done": True, + "done_reason": "stop", + }, + ), + ) as mock_chat: + await conversation.async_converse( + hass, + "test message", + conversation_id, + Context(), + agent_id=mock_config_entry.entry_id, + ) + + await conversation.async_converse( + hass, + "test message 2", + conversation_id, + Context(), + agent_id=mock_config_entry.entry_id, + ) + + assert mock_chat.call_count == 2 + assert mock_chat.call_args.kwargs["messages"][1:] == [ + Message(role="user", content="test message"), + Message( + role="assistant", + content="test response", + thinking="test thinking", + ), + Message(role="user", content="test message 2"), + ] + + async def test_template_variables( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 766de8a7d6d..25e41daf276 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -372,6 +372,8 @@ async def test_migration_from_v1_with_same_urls( @pytest.mark.parametrize( ( "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", "merged_config_entry_disabled_by", "conversation_subentry_data", "main_config_entry", @@ -379,6 +381,8 @@ async def test_migration_from_v1_with_same_urls( [ ( [ConfigEntryDisabler.USER, None], + [DeviceEntryDisabler.CONFIG_ENTRY, None], + [RegistryEntryDisabler.CONFIG_ENTRY, None], None, [ { @@ -398,18 +402,20 @@ async def test_migration_from_v1_with_same_urls( ), ( [None, ConfigEntryDisabler.USER], + [None, DeviceEntryDisabler.CONFIG_ENTRY], + [None, RegistryEntryDisabler.CONFIG_ENTRY], None, [ { "conversation_entity_id": "conversation.ollama", - "device_disabled_by": DeviceEntryDisabler.USER, - "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device_disabled_by": None, + "entity_disabled_by": None, "device": 0, }, { "conversation_entity_id": "conversation.ollama_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, "device": 1, }, ], @@ -417,6 +423,8 @@ async def test_migration_from_v1_with_same_urls( ), ( [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + [DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY], + [RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY], ConfigEntryDisabler.USER, [ { @@ -427,8 +435,8 @@ async def test_migration_from_v1_with_same_urls( }, { "conversation_entity_id": "conversation.ollama_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, "device": 1, }, ], @@ -441,6 +449,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, @@ -474,7 +484,7 @@ async def test_migration_from_v1_disabled( manufacturer="Ollama", model="Ollama", entry_type=dr.DeviceEntryType.SERVICE, - disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + disabled_by=device_disabled_by[0], ) entity_registry.async_get_or_create( "conversation", @@ -483,7 +493,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry, device_id=device_1.id, suggested_object_id="ollama", - disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + disabled_by=entity_disabled_by[0], ) device_2 = device_registry.async_get_or_create( @@ -493,6 +503,7 @@ async def test_migration_from_v1_disabled( manufacturer="Ollama", model="Ollama", entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=device_disabled_by[1], ) entity_registry.async_get_or_create( "conversation", @@ -501,6 +512,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry_2, device_id=device_2.id, suggested_object_id="ollama_2", + disabled_by=entity_disabled_by[1], ) devices = [device_1, device_2] diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index f8580c2b257..0e84d504ec8 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -29,12 +29,12 @@ RECEIVER_INFO_2 = ReceiverInfo( def mock_discovery(receiver_infos: Iterable[ReceiverInfo] | None) -> Generator[None]: """Mock discovery functions.""" - async def get_info(host: str) -> ReceiverInfo | None: + async def get_info(host: str) -> ReceiverInfo: """Get receiver info by host.""" for info in receiver_infos: if info.host == host: return info - return None + raise TimeoutError def get_infos(host: str) -> MagicMock: """Get receiver infos from broadcast.""" diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index b56ab4b7028..8ea8febf7c3 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -14,7 +14,7 @@ from homeassistant.components.onkyo.const import ( OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, ) -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -240,6 +240,57 @@ async def test_eiscp_discovery_error( assert result["reason"] == error_reason +async def test_eiscp_discovery_replace_ignored_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test eiscp discovery can replace an ignored config entry.""" + mock_config_entry.source = SOURCE_IGNORE + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "eiscp_discovery" + + devices = result["data_schema"].schema["device"].container + assert devices == { + RECEIVER_INFO.identifier: _receiver_display_name(RECEIVER_INFO), + RECEIVER_INFO_2.identifier: _receiver_display_name(RECEIVER_INFO_2), + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device": RECEIVER_INFO.identifier} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO.host + assert result["result"].unique_id == RECEIVER_INFO.identifier + assert result["title"] == RECEIVER_INFO.model_name + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + @pytest.mark.usefixtures("mock_setup_entry") async def test_ssdp_discovery( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/onvif/test_button.py b/tests/components/onvif/test_button.py index 209733a0f78..60fada5e06a 100644 --- a/tests/components/onvif/test_button.py +++ b/tests/components/onvif/test_button.py @@ -60,7 +60,7 @@ async def test_set_dateandtime_button( async def test_set_dateandtime_button_press(hass: HomeAssistant) -> None: """Test SetDateAndTime button press.""" - _, camera, device = await setup_onvif_integration(hass) + _, _camera, device = await setup_onvif_integration(hass) device.async_manually_set_date_and_time = AsyncMock(return_value=True) await hass.services.async_call( diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index e8effca3bc5..fb19236034f 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -13,6 +13,8 @@ from openai.types.responses import ( ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, ResponseFunctionWebSearch, + ResponseImageGenCallCompletedEvent, + ResponseImageGenCallPartialImageEvent, ResponseOutputItemAddedEvent, ResponseOutputItemDoneEvent, ResponseOutputMessage, @@ -31,6 +33,7 @@ from openai.types.responses import ( ) from openai.types.responses.response_code_interpreter_tool_call import OutputLogs from openai.types.responses.response_function_web_search import ActionSearch +from openai.types.responses.response_output_item import ImageGenerationCall from openai.types.responses.response_reasoning_item import Summary @@ -401,3 +404,45 @@ def create_code_interpreter_item( ) return events + + +def create_image_gen_call_item( + id: str, output_index: int, logs: str | None = None +) -> list[ResponseStreamEvent]: + """Create a message item.""" + return [ + ResponseImageGenCallPartialImageEvent( + item_id=id, + output_index=output_index, + partial_image_b64="QQ==", + partial_image_index=0, + sequence_number=0, + type="response.image_generation_call.partial_image", + size="1536x1024", + quality="medium", + background="transparent", + output_format="png", + ), + ResponseImageGenCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.image_generation_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ImageGenerationCall( + id=id, + result="QQ==", + status="completed", + type="image_generation_call", + background="transparent", + output_format="png", + quality="medium", + revised_prompt="Mock revised prompt.", + size="1536x1024", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 14e3056c0e2..b9a69e5f77e 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -3,15 +3,19 @@ from pathlib import Path from unittest.mock import AsyncMock, patch +from freezegun import freeze_time +import httpx +from openai import PermissionDeniedError import pytest import voluptuous as vol from homeassistant.components import ai_task, media_source +from homeassistant.components.openai_conversation import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er, selector +from homeassistant.helpers import entity_registry as er, issue_registry as ir, selector -from . import create_message_item +from . import create_image_gen_call_item, create_message_item from tests.common import MockConfigEntry @@ -151,14 +155,13 @@ async def test_generate_data_with_attachments( path=Path("doorbell_snapshot.jpg"), ), media_source.PlayMedia( - url="http://example.com/context.txt", - mime_type="text/plain", - path=Path("context.txt"), + url="http://example.com/context.pdf", + mime_type="application/pdf", + path=Path("context.pdf"), ), ], ), patch("pathlib.Path.exists", return_value=True), - # patch.object(hass.config, "is_allowed_path", return_value=True), patch( "homeassistant.components.openai_conversation.entity.guess_file_type", return_value=("image/jpeg", None), @@ -172,7 +175,7 @@ async def test_generate_data_with_attachments( instructions="Test prompt", attachments=[ {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, - {"media_content_id": "media-source://media/context.txt"}, + {"media_content_id": "media-source://media/context.pdf"}, ], ) @@ -201,8 +204,100 @@ async def test_generate_data_with_attachments( "type": "input_image", }, { - "detail": "auto", - "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", - "type": "input_image", + "filename": "context.pdf", + "file_data": "data:application/pdf;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_file", }, ] + + +@pytest.mark.usefixtures("mock_init_component") +@freeze_time("2025-06-14 22:59:00") +async def test_generate_image( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test AI Task image generation.""" + entity_id = "ai_task.openai_ai_task" + + # Ensure entity is linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_image_gen_call_item(id="ig_A", output_index=0), + create_message_item(id="msg_A", text="", output_index=1), + ] + + with patch.object( + media_source.local_source.LocalSource, + "async_upload_media", + return_value="media-source://ai_task/image/2025-06-14_225900_test_task.png", + ) as mock_upload_media: + result = await ai_task.async_generate_image( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test image", + ) + + assert result["height"] == 1024 + assert result["width"] == 1536 + assert result["revised_prompt"] == "Mock revised prompt." + assert result["mime_type"] == "image/png" + assert result["model"] == "gpt-image-1" + + mock_upload_media.assert_called_once() + image_data = mock_upload_media.call_args[0][1] + assert image_data.file.getvalue() == b"A" + assert image_data.content_type == "image/png" + assert image_data.filename == "2025-06-14_225900_test_task.png" + + assert ( + issue_registry.async_get_issue(DOMAIN, "organization_verification_required") + is None + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_repair_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that repair issue is raised when verification is required.""" + with ( + patch( + "openai.resources.responses.AsyncResponses.create", + side_effect=PermissionDeniedError( + response=httpx.Response( + status_code=403, request=httpx.Request(method="GET", url="") + ), + body=None, + message="Please click on Verify Organization.", + ), + ), + pytest.raises(HomeAssistantError, match="Error talking to OpenAI"), + ): + await ai_task.async_generate_image( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test image", + ) + + assert issue_registry.async_get_issue(DOMAIN, "organization_verification_required") diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 66afc41826b..70d873752ae 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -868,6 +868,8 @@ async def test_migration_from_v1_with_same_keys( @pytest.mark.parametrize( ( "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", "merged_config_entry_disabled_by", "conversation_subentry_data", "main_config_entry", @@ -875,6 +877,8 @@ async def test_migration_from_v1_with_same_keys( [ ( [ConfigEntryDisabler.USER, None], + [DeviceEntryDisabler.CONFIG_ENTRY, None], + [RegistryEntryDisabler.CONFIG_ENTRY, None], None, [ { @@ -894,18 +898,20 @@ async def test_migration_from_v1_with_same_keys( ), ( [None, ConfigEntryDisabler.USER], + [None, DeviceEntryDisabler.CONFIG_ENTRY], + [None, RegistryEntryDisabler.CONFIG_ENTRY], None, [ { "conversation_entity_id": "conversation.chatgpt", - "device_disabled_by": DeviceEntryDisabler.USER, - "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device_disabled_by": None, + "entity_disabled_by": None, "device": 0, }, { "conversation_entity_id": "conversation.chatgpt_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, "device": 1, }, ], @@ -913,6 +919,8 @@ async def test_migration_from_v1_with_same_keys( ), ( [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + [DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY], + [RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY], ConfigEntryDisabler.USER, [ { @@ -923,8 +931,8 @@ async def test_migration_from_v1_with_same_keys( }, { "conversation_entity_id": "conversation.chatgpt_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, "device": 1, }, ], @@ -937,6 +945,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, @@ -976,7 +986,7 @@ async def test_migration_from_v1_disabled( manufacturer="OpenAI", model="ChatGPT", entry_type=dr.DeviceEntryType.SERVICE, - disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + disabled_by=device_disabled_by[0], ) entity_registry.async_get_or_create( "conversation", @@ -985,7 +995,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry, device_id=device_1.id, suggested_object_id="chatgpt", - disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + disabled_by=entity_disabled_by[0], ) device_2 = device_registry.async_get_or_create( @@ -995,6 +1005,7 @@ async def test_migration_from_v1_disabled( manufacturer="OpenAI", model="ChatGPT", entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=device_disabled_by[1], ) entity_registry.async_get_or_create( "conversation", @@ -1003,6 +1014,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry_2, device_id=device_2.id, suggested_object_id="chatgpt_2", + disabled_by=entity_disabled_by[1], ) devices = [device_1, device_2] diff --git a/tests/components/openuv/snapshots/test_binary_sensor.ambr b/tests/components/openuv/snapshots/test_binary_sensor.ambr index ef52d36fb6e..8c88392f7cc 100644 --- a/tests/components/openuv/snapshots/test_binary_sensor.ambr +++ b/tests/components/openuv/snapshots/test_binary_sensor.ambr @@ -51,3 +51,54 @@ 'state': 'off', }) # --- +# name: test_protection_window_recalculation[after-protetction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end_time': datetime.datetime(2018, 7, 30, 16, 47, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'end_uv': 3.6483, + 'friendly_name': 'OpenUV Protection window', + 'start_time': datetime.datetime(2018, 7, 30, 9, 17, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'start_uv': 3.2509, + }), + 'context': , + 'entity_id': 'binary_sensor.openuv_protection_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_protection_window_recalculation[before-protetction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end_time': datetime.datetime(2018, 7, 30, 16, 47, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'end_uv': 3.6483, + 'friendly_name': 'OpenUV Protection window', + 'start_time': datetime.datetime(2018, 7, 30, 9, 17, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'start_uv': 3.2509, + }), + 'context': , + 'entity_id': 'binary_sensor.openuv_protection_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_protection_window_recalculation[during-protetction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end_time': datetime.datetime(2018, 7, 30, 16, 47, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'end_uv': 3.6483, + 'friendly_name': 'OpenUV Protection window', + 'start_time': datetime.datetime(2018, 7, 30, 9, 17, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'start_uv': 3.2509, + }), + 'context': , + 'entity_id': 'binary_sensor.openuv_protection_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/openuv/test_binary_sensor.py b/tests/components/openuv/test_binary_sensor.py index d6025b9ed20..1885966c4f9 100644 --- a/tests/components/openuv/test_binary_sensor.py +++ b/tests/components/openuv/test_binary_sensor.py @@ -1,21 +1,26 @@ """Test OpenUV binary sensors.""" -from typing import Literal from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.homeassistant import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.const import ATTR_ENTITY_ID, 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 tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_binary_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_pyopenuv: Literal[None], + mock_pyopenuv: None, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, ) -> None: @@ -25,3 +30,77 @@ async def test_binary_sensors( await hass.async_block_till_done() await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_protetction_window_update( + hass: HomeAssistant, + set_time_zone, + config, + client, + config_entry, + setup_config_entry, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that updating the protetection window makes an extra API call.""" + + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + + assert client.uv_protection_window.call_count == 1 + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "binary_sensor.openuv_protection_window"}, + blocking=True, + ) + + assert client.uv_protection_window.call_count == 2 + + +async def test_protection_window_recalculation( + hass: HomeAssistant, + config, + config_entry, + snapshot: SnapshotAssertion, + set_time_zone, + mock_pyopenuv, + client, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that protetction window updates automatically without extra API calls.""" + + freezer.move_to("2018-07-30T06:17:59-06:00") + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "binary_sensor.openuv_protection_window" + state = hass.states.get(entity_id) + assert state.state == "off" + assert state == snapshot(name="before-protetction-state") + + # move to when the protetction window starts + freezer.move_to("2018-07-30T09:17:59-06:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = "binary_sensor.openuv_protection_window" + state = hass.states.get(entity_id) + assert state.state == "on" + assert state == snapshot(name="during-protetction-state") + + # move to when the protetction window ends + freezer.move_to("2018-07-30T16:47:59-06:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = "binary_sensor.openuv_protection_window" + state = hass.states.get(entity_id) + assert state.state == "off" + assert state == snapshot(name="after-protetction-state") + + assert client.uv_protection_window.call_count == 1 diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 03b392b3e7b..27cf79ae200 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -43,9 +43,10 @@ async def test_entry_diagnostics( }, "data": { "protection_window": { - "from_time": "2018-07-30T15:17:49.750Z", + "is_on": False, + "from_time": "2018-07-30T15:17:49.750000+00:00", "from_uv": 3.2509, - "to_time": "2018-07-30T22:47:49.750Z", + "to_time": "2018-07-30T22:47:49.750000+00:00", "to_uv": 3.6483, }, "uv": { diff --git a/tests/components/openuv/test_sensor.py b/tests/components/openuv/test_sensor.py index 93106aedc35..d91095afd08 100644 --- a/tests/components/openuv/test_sensor.py +++ b/tests/components/openuv/test_sensor.py @@ -1,6 +1,5 @@ """Test OpenUV sensors.""" -from typing import Literal from unittest.mock import patch from syrupy.assertion import SnapshotAssertion @@ -15,7 +14,7 @@ from tests.common import MockConfigEntry, snapshot_platform async def test_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_pyopenuv: Literal[None], + mock_pyopenuv: None, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, ) -> None: diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index 073715c87ec..be3db7bc594 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -72,6 +72,7 @@ 'pressure_unit': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, @@ -136,6 +137,7 @@ 'supported_features': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, @@ -200,6 +202,7 @@ 'supported_features': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index 147f20733d6..8c2c11c7dbc 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -101,7 +101,7 @@ async def test_sensors_io_series_4(hass: HomeAssistant) -> None: toothbrush_sensor = hass.states.get("sensor.io_series_4_48be_brushing_mode") toothbrush_sensor_attrs = toothbrush_sensor.attributes - assert toothbrush_sensor.state == "gum care" + assert toothbrush_sensor.state == "gum_care" assert ( toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "IO Series 4 48BE Brushing mode" ) @@ -133,7 +133,7 @@ async def test_sensors_io_series_4(hass: HomeAssistant) -> None: toothbrush_sensor = hass.states.get("sensor.io_series_4_48be_brushing_mode") # Sleepy devices should keep their state over time - assert toothbrush_sensor.state == "gum care" + assert toothbrush_sensor.state == "gum_care" toothbrush_sensor_attrs = toothbrush_sensor.attributes assert toothbrush_sensor_attrs[ATTR_ASSUMED_STATE] is True diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 8384b905b9c..1b6926a863c 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -455,7 +455,7 @@ async def test_hassio_discovery_flow_yellow( [ ( "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", - "Home Assistant SkyConnect (Silicon Labs Multiprotocol)", + "Home Assistant Connect ZBT-1 (Silicon Labs Multiprotocol)", ), ( "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", @@ -556,14 +556,16 @@ async def test_hassio_discovery_flow_2x_addons( assert results[0]["type"] is FlowResultType.CREATE_ENTRY assert ( - results[0]["title"] == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + results[0]["title"] + == "Home Assistant Connect ZBT-1 (Silicon Labs Multiprotocol)" ) assert results[0]["data"] == expected_data assert results[0]["options"] == {} assert results[1]["type"] is FlowResultType.CREATE_ENTRY assert ( - results[1]["title"] == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + results[1]["title"] + == "Home Assistant Connect ZBT-1 (Silicon Labs Multiprotocol)" ) assert results[1]["data"] == expected_data_2 assert results[1]["options"] == {} @@ -574,7 +576,8 @@ async def test_hassio_discovery_flow_2x_addons( assert config_entry.data == expected_data assert config_entry.options == {} assert ( - config_entry.title == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + config_entry.title + == "Home Assistant Connect ZBT-1 (Silicon Labs Multiprotocol)" ) assert config_entry.unique_id == HASSIO_DATA.uuid @@ -582,7 +585,8 @@ async def test_hassio_discovery_flow_2x_addons( assert config_entry.data == expected_data_2 assert config_entry.options == {} assert ( - config_entry.title == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + config_entry.title + == "Home Assistant Connect ZBT-1 (Silicon Labs Multiprotocol)" ) assert config_entry.unique_id == HASSIO_DATA_2.uuid @@ -641,7 +645,8 @@ async def test_hassio_discovery_flow_2x_addons_same_ext_address( assert results[0]["type"] is FlowResultType.CREATE_ENTRY assert ( - results[0]["title"] == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + results[0]["title"] + == "Home Assistant Connect ZBT-1 (Silicon Labs Multiprotocol)" ) assert results[0]["data"] == expected_data assert results[0]["options"] == {} @@ -653,7 +658,8 @@ async def test_hassio_discovery_flow_2x_addons_same_ext_address( assert config_entry.data == expected_data assert config_entry.options == {} assert ( - config_entry.title == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + config_entry.title + == "Home Assistant Connect ZBT-1 (Silicon Labs Multiprotocol)" ) assert config_entry.unique_id == HASSIO_DATA.uuid diff --git a/tests/components/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py index cbd89320074..1aa3e48f060 100644 --- a/tests/components/p1_monitor/test_config_flow.py +++ b/tests/components/p1_monitor/test_config_flow.py @@ -22,7 +22,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.p1_monitor.config_flow.P1Monitor.smartmeter" + "homeassistant.components.p1_monitor.config_flow.P1Monitor.settings" ) as mock_p1monitor, patch( "homeassistant.components.p1_monitor.async_setup_entry", return_value=True @@ -45,7 +45,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: async def test_api_error(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" with patch( - "homeassistant.components.p1_monitor.coordinator.P1Monitor.smartmeter", + "homeassistant.components.p1_monitor.coordinator.P1Monitor.settings", side_effect=P1MonitorError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index c001da86adb..81b38f59a3d 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -14,7 +14,9 @@ from homeassistant.components.person import ( DOMAIN, ) from homeassistant.const import ( + ATTR_EDITABLE, ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, ATTR_GPS_ACCURACY, ATTR_ID, ATTR_LATITUDE, @@ -112,14 +114,19 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> } assert await async_setup_component(hass, DOMAIN, config) + expected_attributes = { + ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER], + ATTR_EDITABLE: False, + ATTR_FRIENDLY_NAME: "tracked person", + ATTR_ID: "1234", + ATTR_USER_ID: user_id, + } + state = hass.states.get("person.tracked_person") assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) is None - assert state.attributes.get(ATTR_LONGITUDE) is None - assert state.attributes.get(ATTR_SOURCE) is None - assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes == expected_attributes + # Test home without coordinates hass.states.async_set(DEVICE_TRACKER, "home") await hass.async_block_till_done() @@ -131,13 +138,41 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> state = hass.states.get("person.tracked_person") assert state.state == "home" - assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) is None - assert state.attributes.get(ATTR_LONGITUDE) is None - assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER - assert state.attributes.get(ATTR_USER_ID) == user_id - assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [DEVICE_TRACKER] + assert state.attributes == expected_attributes | { + ATTR_LATITUDE: 32.87336, + ATTR_LONGITUDE: -117.22743, + ATTR_SOURCE: DEVICE_TRACKER, + } + # Test home with coordinates + hass.states.async_set( + DEVICE_TRACKER, + "home", + {ATTR_LATITUDE: 10.123456, ATTR_LONGITUDE: 11.123456, ATTR_GPS_ACCURACY: 10}, + ) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == "home" + assert state.attributes == expected_attributes | { + ATTR_GPS_ACCURACY: 10, + ATTR_LATITUDE: 10.123456, + ATTR_LONGITUDE: 11.123456, + ATTR_SOURCE: DEVICE_TRACKER, + } + + # Test not_home without coordinates + hass.states.async_set( + DEVICE_TRACKER, + "not_home", + ) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == "not_home" + assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER} + + # Test not_home with coordinates hass.states.async_set( DEVICE_TRACKER, "not_home", @@ -147,13 +182,12 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> state = hass.states.get("person.tracked_person") assert state.state == "not_home" - assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) == 10.123456 - assert state.attributes.get(ATTR_LONGITUDE) == 11.123456 - assert state.attributes.get(ATTR_GPS_ACCURACY) == 10 - assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER - assert state.attributes.get(ATTR_USER_ID) == user_id - assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [DEVICE_TRACKER] + assert state.attributes == expected_attributes | { + ATTR_GPS_ACCURACY: 10, + ATTR_LATITUDE: 10.123456, + ATTR_LONGITUDE: 11.123456, + ATTR_SOURCE: DEVICE_TRACKER, + } async def test_setup_two_trackers( @@ -188,8 +222,8 @@ async def test_setup_two_trackers( state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) is None - assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_LATITUDE) == 32.87336 + assert state.attributes.get(ATTR_LONGITUDE) == -117.22743 assert state.attributes.get(ATTR_GPS_ACCURACY) is None assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == user_id @@ -453,8 +487,8 @@ async def test_load_person_storage( state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) is None - assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_LATITUDE) == 32.87336 + assert state.attributes.get(ATTR_LONGITUDE) == -117.22743 assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == hass_admin_user.id @@ -817,7 +851,7 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: }, ) - assert len(hass.states.async_entity_ids()) == 2 + assert len(hass.states.async_entity_ids()) == 3 # Person1, Person2, zone.home state_1 = hass.states.get("person.person_1") state_2 = hass.states.get("person.person_2") @@ -847,7 +881,7 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: ) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 2 + assert len(hass.states.async_entity_ids()) == 3 # Person1, Person2, zone.home state_1 = hass.states.get("person.person_1") state_2 = hass.states.get("person.person_2") diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index bfbdc9a72bd..f81f3842d80 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -184,6 +184,7 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: fren = MagicMock( spec=User, account_id="fren-psn-id", online_id="PublicUniversalFriend" ) + fren.get_presence.return_value = mock_user.get_presence.return_value client.user.return_value.friends_list.return_value = [fren] diff --git a/tests/components/playstation_network/snapshots/test_notify.ambr b/tests/components/playstation_network/snapshots/test_notify.ambr index d8c32918433..416b1da46ca 100644 --- a/tests/components/playstation_network/snapshots/test_notify.ambr +++ b/tests/components/playstation_network/snapshots/test_notify.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_notify_platform[notify.testuser_direct_message-entry] +# name: test_notify_platform[notify.testuser_direct_message_publicuniversalfriend-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,7 +12,7 @@ 'disabled_by': None, 'domain': 'notify', 'entity_category': None, - 'entity_id': 'notify.testuser_direct_message', + 'entity_id': 'notify.testuser_direct_message_publicuniversalfriend', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24,24 +24,24 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Direct message', + 'original_name': 'Direct message: PublicUniversalFriend', 'platform': 'playstation_network', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': 'fren-psn-id_direct_message', + 'unique_id': 'my-psn-id_fren-psn-id_direct_message', 'unit_of_measurement': None, }) # --- -# name: test_notify_platform[notify.testuser_direct_message-state] +# name: test_notify_platform[notify.testuser_direct_message_publicuniversalfriend-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testuser Direct message', + 'friendly_name': 'testuser Direct message: PublicUniversalFriend', 'supported_features': , }), 'context': , - 'entity_id': 'notify.testuser_direct_message', + 'entity_id': 'notify.testuser_direct_message_publicuniversalfriend', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/playstation_network/snapshots/test_sensor.ambr b/tests/components/playstation_network/snapshots/test_sensor.ambr index 046989cebe6..9d550e546b0 100644 --- a/tests/components/playstation_network/snapshots/test_sensor.ambr +++ b/tests/components/playstation_network/snapshots/test_sensor.ambr @@ -1,4 +1,211 @@ # serializer version: 1 +# name: test_sensors[sensor.publicuniversalfriend_last_online-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': None, + 'entity_id': 'sensor.publicuniversalfriend_last_online', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.publicuniversalfriend_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'PublicUniversalFriend Last online', + }), + 'context': , + 'entity_id': 'sensor.publicuniversalfriend_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-06-30T01:42:15+00:00', + }) +# --- +# name: test_sensors[sensor.publicuniversalfriend_now_playing-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': None, + 'entity_id': 'sensor.publicuniversalfriend_now_playing', + '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': 'Now playing', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.publicuniversalfriend_now_playing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PublicUniversalFriend Now playing', + }), + 'context': , + 'entity_id': 'sensor.publicuniversalfriend_now_playing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'STAR WARS Jedi: Survivor™', + }) +# --- +# name: test_sensors[sensor.publicuniversalfriend_online_id-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': None, + 'entity_id': 'sensor.publicuniversalfriend_online_id', + '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': 'Online ID', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_online_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.publicuniversalfriend_online_id-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PublicUniversalFriend Online ID', + }), + 'context': , + 'entity_id': 'sensor.publicuniversalfriend_online_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'PublicUniversalFriend', + }) +# --- +# name: test_sensors[sensor.publicuniversalfriend_online_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.publicuniversalfriend_online_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': 'Online status', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_online_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.publicuniversalfriend_online_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'PublicUniversalFriend Online status', + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'context': , + 'entity_id': 'sensor.publicuniversalfriend_online_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'availabletoplay', + }) +# --- # name: test_sensors[sensor.testuser_bronze_trophies-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -146,55 +353,6 @@ 'state': '2025-06-30T01:42:15+00:00', }) # --- -# name: test_sensors[sensor.testuser_last_online_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': None, - 'entity_id': 'sensor.testuser_last_online_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last online', - 'platform': 'playstation_network', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'fren-psn-id_last_online', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.testuser_last_online_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'testuser Last online', - }), - 'context': , - 'entity_id': 'sensor.testuser_last_online_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-06-30T01:42:15+00:00', - }) -# --- # name: test_sensors[sensor.testuser_next_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -292,54 +450,6 @@ 'state': 'STAR WARS Jedi: Survivor™', }) # --- -# name: test_sensors[sensor.testuser_now_playing_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': None, - 'entity_id': 'sensor.testuser_now_playing_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': 'Now playing', - 'platform': 'playstation_network', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'fren-psn-id_now_playing', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.testuser_now_playing_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'testuser Now playing', - }), - 'context': , - 'entity_id': 'sensor.testuser_now_playing_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'STAR WARS Jedi: Survivor™', - }) -# --- # name: test_sensors[sensor.testuser_online_id-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -389,55 +499,6 @@ 'state': 'testuser', }) # --- -# name: test_sensors[sensor.testuser_online_id_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': None, - 'entity_id': 'sensor.testuser_online_id_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': 'Online ID', - 'platform': 'playstation_network', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'fren-psn-id_online_id', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.testuser_online_id_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'entity_picture': 'http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png', - 'friendly_name': 'testuser Online ID', - }), - 'context': , - 'entity_id': 'sensor.testuser_online_id_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'testuser', - }) -# --- # name: test_sensors[sensor.testuser_online_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -500,68 +561,6 @@ 'state': 'availabletoplay', }) # --- -# name: test_sensors[sensor.testuser_online_status_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'offline', - 'availabletoplay', - 'availabletocommunicate', - 'busy', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.testuser_online_status_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Online status', - 'platform': 'playstation_network', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'fren-psn-id_online_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.testuser_online_status_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'testuser Online status', - 'options': list([ - 'offline', - 'availabletoplay', - 'availabletocommunicate', - 'busy', - ]), - }), - 'context': , - 'entity_id': 'sensor.testuser_online_status_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'availabletoplay', - }) -# --- # name: test_sensors[sensor.testuser_platinum_trophies-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index 0cd94fe153a..14c5633d384 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -501,6 +501,7 @@ async def test_add_friend_flow_no_friends( mock_psnawpapi: MagicMock, ) -> None: """Test we abort add friend subentry flow when the user has no friends.""" + mock_psnawpapi.user.return_value.friends_list.return_value = [] config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -508,8 +509,6 @@ async def test_add_friend_flow_no_friends( assert config_entry.state is ConfigEntryState.LOADED - mock_psnawpapi.user.return_value.friends_list.return_value = [] - result = await hass.config_entries.subentries.async_init( (config_entry.entry_id, "friend"), context={"source": SOURCE_USER}, diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py index 6db4cb6ab6a..e5a361a3cfb 100644 --- a/tests/components/playstation_network/test_init.py +++ b/tests/components/playstation_network/test_init.py @@ -278,10 +278,9 @@ async def test_friends_coordinator_update_data_failed( ) -> None: """Test friends coordinator setup fails in _update_data.""" - mock_psnawpapi.user.return_value.get_presence.side_effect = [ - mock_psnawpapi.user.return_value.get_presence.return_value, - exception, - ] + mock = mock_psnawpapi.user.return_value.friends_list.return_value[0] + mock.get_presence.side_effect = exception + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -306,11 +305,9 @@ async def test_friends_coordinator_setup_failed( state: ConfigEntryState, ) -> None: """Test friends coordinator setup fails in _async_setup.""" + mock = mock_psnawpapi.user.return_value.friends_list.return_value[0] + mock.profile.side_effect = exception - mock_psnawpapi.user.side_effect = [ - mock_psnawpapi.user.return_value, - exception, - ] config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -324,10 +321,10 @@ async def test_friends_coordinator_auth_failed( mock_psnawpapi: MagicMock, ) -> None: """Test friends coordinator starts reauth on authentication error.""" - mock_psnawpapi.user.side_effect = [ - mock_psnawpapi.user.return_value, - PSNAWPAuthenticationError, - ] + + mock = mock_psnawpapi.user.return_value.friends_list.return_value[0] + mock.profile.side_effect = PSNAWPAuthenticationError + 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/playstation_network/test_notify.py b/tests/components/playstation_network/test_notify.py index f81e03dfcc4..a4ef6584a6e 100644 --- a/tests/components/playstation_network/test_notify.py +++ b/tests/components/playstation_network/test_notify.py @@ -18,11 +18,12 @@ from homeassistant.components.notify import ( DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, ) +from homeassistant.components.playstation_network.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from tests.common import MockConfigEntry, snapshot_platform @@ -37,7 +38,7 @@ async def notify_only() -> AsyncGenerator[None]: yield -@pytest.mark.usefixtures("mock_psnawpapi") +@pytest.mark.usefixtures("mock_psnawpapi", "entity_registry_enabled_by_default") async def test_notify_platform( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -57,9 +58,13 @@ async def test_notify_platform( @pytest.mark.parametrize( "entity_id", - ["notify.testuser_group_publicuniversalfriend", "notify.testuser_direct_message"], + [ + "notify.testuser_group_publicuniversalfriend", + "notify.testuser_direct_message_publicuniversalfriend", + ], ) @freeze_time("2025-07-28T00:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_send_message( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -130,3 +135,29 @@ async def test_send_message_exceptions( ) mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") + + +async def test_notify_skip_forbidden( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we skip creation of notifiers if forbidden by parental controls.""" + + mock_psnawpapi.me.return_value.get_groups.side_effect = PSNAWPForbiddenError( + """{"error": {"message": "Not permitted by parental control"}}""" + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state is None + + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"group_chat_forbidden_{config_entry.entry_id}" + ) diff --git a/tests/components/plugwise/snapshots/test_climate.ambr b/tests/components/plugwise/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0edb29fabda --- /dev/null +++ b/tests/components/plugwise/snapshots/test_climate.ambr @@ -0,0 +1,826 @@ +# serializer version: 1 +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.bathroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bathroom', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'f871b8c4d63549319221e294e4f88074-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.bathroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 17.9, + 'friendly_name': 'Bathroom', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 15.0, + }), + 'context': , + 'entity_id': 'climate.bathroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 1.0, + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'f2bf9048bef64cc5b6d5110154e33c81-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.1, + 'friendly_name': 'Living room', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 1.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.badkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.badkamer', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '08963fec7c53423ca5680aa4cb502c63-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.badkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.9, + 'friendly_name': 'Badkamer', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'away', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 14.0, + }), + 'context': , + 'entity_id': 'climate.badkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.bios-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bios', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.bios-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 16.5, + 'friendly_name': 'Bios', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'away', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 13.0, + }), + 'context': , + 'entity_id': 'climate.bios', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.garage', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '446ac08dd04d4eff8ac57489757b7314-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 15.6, + 'friendly_name': 'Garage', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'no_frost', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 5.5, + }), + 'context': , + 'entity_id': 'climate.garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.jessie-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.jessie', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '82fa13f017d240daa0d0ea1775420f24-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.jessie-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 17.2, + 'friendly_name': 'Jessie', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'asleep', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 15.0, + }), + 'context': , + 'entity_id': 'climate.jessie', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.woonkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.woonkamer', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.woonkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.9, + 'friendly_name': 'Woonkamer', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.woonkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_2_climate_snapshot[platforms0-True-m_anna_heatpump_cooling][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_2_climate_snapshot[platforms0-True-m_anna_heatpump_cooling][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.3, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_3_climate_snapshot[platforms0-True-m_anna_heatpump_idle][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_3_climate_snapshot[platforms0-True-m_anna_heatpump_idle][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.0, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_climate_snapshot[platforms0-True-anna_heatpump_heating][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + '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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_climate_snapshot[platforms0-True-anna_heatpump_heating][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.3, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/plugwise/snapshots/test_number.ambr b/tests/components/plugwise/snapshots/test_number.ambr new file mode 100644 index 00000000000..922cbb1e2bf --- /dev/null +++ b/tests/components/plugwise/snapshots/test_number.ambr @@ -0,0 +1,709 @@ +# serializer version: 1 +# name: test_adam_number_entities[platforms0][number.bios_cv_thermostatic_radiator_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.bios_cv_thermostatic_radiator_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.bios_cv_thermostatic_radiator_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Cv Thermostatic Radiator Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.bios_cv_thermostatic_radiator_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.cv_kraan_garage_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.cv_kraan_garage_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.cv_kraan_garage_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'CV Kraan Garage Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.cv_kraan_garage_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.floor_kraan_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.floor_kraan_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.floor_kraan_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor kraan Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.floor_kraan_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_badkamer_1_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.thermostatic_radiator_badkamer_1_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '680423ff840043738f42cc7f1ff97a36-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_badkamer_1_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.thermostatic_radiator_badkamer_1_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_badkamer_2_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.thermostatic_radiator_badkamer_2_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_badkamer_2_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.thermostatic_radiator_badkamer_2_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_jessie_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.thermostatic_radiator_jessie_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.thermostatic_radiator_jessie_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Jessie Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.thermostatic_radiator_jessie_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_lisa_bios_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.zone_lisa_bios_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_lisa_bios_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa Bios Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.zone_lisa_bios_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_lisa_wk_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.zone_lisa_wk_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_lisa_wk_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa WK Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.zone_lisa_wk_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_thermostat_jessie_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.zone_thermostat_jessie_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_number_entities[platforms0][number.zone_thermostat_jessie_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Thermostat Jessie Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.zone_thermostat_jessie_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.anna_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.anna_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-temperature_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.anna_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Anna Temperature offset', + 'max': 2.0, + 'min': -2.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.anna_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.5', + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.opentherm_domestic_hot_water_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60.0, + 'min': 35.0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.opentherm_domestic_hot_water_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Domestic hot water setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_dhw_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-max_dhw_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.opentherm_domestic_hot_water_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Domestic hot water setpoint', + 'max': 60.0, + 'min': 35.0, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.opentherm_domestic_hot_water_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.opentherm_maximum_boiler_temperature_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.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.opentherm_maximum_boiler_temperature_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum boiler temperature setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_boiler_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-maximum_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_number_entities[platforms0-True-anna_heatpump_heating][number.opentherm_maximum_boiler_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Maximum boiler temperature setpoint', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.opentherm_maximum_boiler_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- diff --git a/tests/components/plugwise/snapshots/test_select.ambr b/tests/components/plugwise/snapshots/test_select.ambr new file mode 100644 index 00000000000..c83e56a3446 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_select.ambr @@ -0,0 +1,509 @@ +# serializer version: 1 +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_gateway_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'away', + 'full', + 'vacation', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.adam_gateway_mode', + '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': 'Gateway mode', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gateway_mode', + 'unique_id': 'da224107914542988a88561b4452b0f6-select_gateway_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_gateway_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Adam Gateway mode', + 'options': list([ + 'away', + 'full', + 'vacation', + ]), + }), + 'context': , + 'entity_id': 'select.adam_gateway_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'full', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_regulation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'bleeding_hot', + 'bleeding_cold', + 'off', + 'heating', + 'cooling', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.adam_regulation_mode', + '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': 'Regulation mode', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'regulation_mode', + 'unique_id': 'da224107914542988a88561b4452b0f6-select_regulation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_regulation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Adam Regulation mode', + 'options': list([ + 'bleeding_hot', + 'bleeding_cold', + 'off', + 'heating', + 'cooling', + ]), + }), + 'context': , + 'entity_id': 'select.adam_regulation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cooling', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.bathroom_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bathroom_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'f871b8c4d63549319221e294e4f88074-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.bathroom_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bathroom Thermostat schedule', + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bathroom_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Badkamer', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.living_room_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.living_room_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'f2bf9048bef64cc5b6d5110154e33c81-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.living_room_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room Thermostat schedule', + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.living_room_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_select_entities[platforms0][select.badkamer_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.badkamer_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '08963fec7c53423ca5680aa4cb502c63-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.badkamer_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Badkamer Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.badkamer_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Badkamer Schema', + }) +# --- +# name: test_adam_select_entities[platforms0][select.bios_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bios_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.bios_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bios Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bios_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_select_entities[platforms0][select.jessie_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.jessie_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '82fa13f017d240daa0d0ea1775420f24-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.jessie_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Jessie Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.jessie_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CV Jessie', + }) +# --- +# name: test_adam_select_entities[platforms0][select.woonkamer_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.woonkamer_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.woonkamer_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Woonkamer Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.woonkamer_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'GF7 Woonkamer', + }) +# --- diff --git a/tests/components/plugwise/snapshots/test_sensor.ambr b/tests/components/plugwise/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..962493c98ce --- /dev/null +++ b/tests/components/plugwise/snapshots/test_sensor.ambr @@ -0,0 +1,8062 @@ +# serializer version: 1 +# name: test_adam_sensor_snapshot[platforms0][sensor.adam_outdoor_temperature-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.adam_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': 'fe799307f1624099878210aa0b9f1475-outdoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.adam_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Adam Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.adam_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.81', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.badkamer_temperature-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': , + 'entity_id': 'sensor.badkamer_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08963fec7c53423ca5680aa4cb502c63-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.badkamer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Badkamer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.badkamer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.9', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_battery-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': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bios Cv Thermostatic Radiator Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_setpoint-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': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Cv Thermostatic Radiator Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_temperature-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': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Cv Thermostatic Radiator Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.2', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_temperature_difference-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': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_temperature_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Cv Thermostatic Radiator Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.2', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_valve_position-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': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_valve_position', + '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': 'Valve position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_cv_thermostatic_radiator_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bios Cv Thermostatic Radiator Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bios_cv_thermostatic_radiator_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_electricity_consumed-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.bios_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Bios Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_electricity_produced-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.bios_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Bios Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_temperature-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': , + 'entity_id': 'sensor.bios_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12493538af164a409c6a1c79e38afe1c-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.bios_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bios Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bios_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_battery-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': , + 'entity_id': 'sensor.cv_kraan_garage_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'CV Kraan Garage Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '68', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_setpoint-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': , + 'entity_id': 'sensor.cv_kraan_garage_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'CV Kraan Garage Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_temperature-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': , + 'entity_id': 'sensor.cv_kraan_garage_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'CV Kraan Garage Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_temperature_difference-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': , + 'entity_id': 'sensor.cv_kraan_garage_temperature_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'CV Kraan Garage Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_valve_position-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': , + 'entity_id': 'sensor.cv_kraan_garage_valve_position', + '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': 'Valve position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_kraan_garage_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CV Kraan Garage Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.cv_kraan_garage_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_consumed-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.cv_pomp_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CV Pomp Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_pomp_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_consumed_interval-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.cv_pomp_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CV Pomp Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_pomp_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.37', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_produced-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.cv_pomp_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CV Pomp Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_pomp_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_produced_interval-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.cv_pomp_electricity_produced_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.cv_pomp_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CV Pomp Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cv_pomp_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_consumed-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.fibaro_hc2_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Fibaro HC2 Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fibaro_hc2_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_consumed_interval-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.fibaro_hc2_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Fibaro HC2 Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fibaro_hc2_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.8', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_produced-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.fibaro_hc2_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Fibaro HC2 Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fibaro_hc2_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_produced_interval-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.fibaro_hc2_electricity_produced_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.fibaro_hc2_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Fibaro HC2 Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fibaro_hc2_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_setpoint-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': , + 'entity_id': 'sensor.floor_kraan_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor kraan Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.floor_kraan_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_temperature-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': , + 'entity_id': 'sensor.floor_kraan_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor kraan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.floor_kraan_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_temperature_difference-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': , + 'entity_id': 'sensor.floor_kraan_temperature_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor kraan Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.floor_kraan_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_valve_position-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': , + 'entity_id': 'sensor.floor_kraan_valve_position', + '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': 'Valve position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': 'b310b72a0e354bfab43089919b9a88bf-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.floor_kraan_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Floor kraan Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.floor_kraan_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.garage_temperature-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': , + 'entity_id': 'sensor.garage_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '446ac08dd04d4eff8ac57489757b7314-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.garage_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Garage Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garage_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.jessie_temperature-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': , + 'entity_id': 'sensor.jessie_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '82fa13f017d240daa0d0ea1775420f24-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.jessie_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Jessie Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.jessie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.2', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_consumed-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.nas_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'NAS Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nas_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_consumed_interval-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.nas_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'NAS Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nas_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_produced-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.nas_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'NAS Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nas_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_produced_interval-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.nas_electricity_produced_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nas_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'NAS Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nas_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_consumed-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.nvr_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '02cf28bfec924855854c544690a609ef-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'NVR Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nvr_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_consumed_interval-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.nvr_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '02cf28bfec924855854c544690a609ef-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'NVR Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nvr_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.15', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_produced-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.nvr_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '02cf28bfec924855854c544690a609ef-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'NVR Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nvr_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_produced_interval-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.nvr_electricity_produced_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '02cf28bfec924855854c544690a609ef-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.nvr_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'NVR Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nvr_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_intended_boiler_temperature-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': , + 'entity_id': 'sensor.onoff_intended_boiler_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intended boiler temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intended_boiler_temperature', + 'unique_id': '90986d591dcd426cae3ec3e8111ff730-intended_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_intended_boiler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OnOff Intended boiler temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.onoff_intended_boiler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_modulation_level-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': , + 'entity_id': 'sensor.onoff_modulation_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': 'Modulation level', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'modulation_level', + 'unique_id': '90986d591dcd426cae3ec3e8111ff730-modulation_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_modulation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OnOff Modulation level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.onoff_modulation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_water_temperature-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': , + 'entity_id': 'sensor.onoff_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '90986d591dcd426cae3ec3e8111ff730-water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.onoff_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OnOff Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.onoff_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_consumed-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.playstation_smart_plug_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Playstation Smart Plug Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.playstation_smart_plug_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '84.1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_consumed_interval-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.playstation_smart_plug_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Playstation Smart Plug Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.playstation_smart_plug_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_produced-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.playstation_smart_plug_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Playstation Smart Plug Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.playstation_smart_plug_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_produced_interval-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.playstation_smart_plug_electricity_produced_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.playstation_smart_plug_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Playstation Smart Plug Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.playstation_smart_plug_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_battery-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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '680423ff840043738f42cc7f1ff97a36-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_setpoint-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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': '680423ff840043738f42cc7f1ff97a36-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_temperature-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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '680423ff840043738f42cc7f1ff97a36-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_temperature_difference-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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_temperature_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': '680423ff840043738f42cc7f1ff97a36-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.4', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_valve_position-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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_valve_position', + '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': 'Valve position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '680423ff840043738f42cc7f1ff97a36-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_1_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_1_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_battery-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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_setpoint-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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_temperature-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': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_badkamer_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_badkamer_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.9', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_battery-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': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Jessie Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_setpoint-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': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Jessie Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_temperature-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': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Jessie Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_temperature_difference-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': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_temperature_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature difference', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_difference', + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-temperature_difference', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_temperature_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostatic Radiator Jessie Temperature difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_temperature_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_valve_position-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': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_valve_position', + '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': 'Valve position', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.thermostatic_radiator_jessie_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostatic Radiator Jessie Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.thermostatic_radiator_jessie_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_consumed-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.usg_smart_plug_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '4a810418d5394b3f82727340b91ba740-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'USG Smart Plug Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.usg_smart_plug_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_consumed_interval-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.usg_smart_plug_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '4a810418d5394b3f82727340b91ba740-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'USG Smart Plug Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.usg_smart_plug_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_produced-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.usg_smart_plug_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '4a810418d5394b3f82727340b91ba740-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'USG Smart Plug Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.usg_smart_plug_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_produced_interval-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.usg_smart_plug_electricity_produced_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '4a810418d5394b3f82727340b91ba740-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.usg_smart_plug_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'USG Smart Plug Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.usg_smart_plug_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_electricity_consumed-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.woonkamer_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Woonkamer Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.woonkamer_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.6', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_electricity_produced-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.woonkamer_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Woonkamer Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.woonkamer_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_temperature-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': , + 'entity_id': 'sensor.woonkamer_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.woonkamer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Woonkamer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.woonkamer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.9', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_consumed-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.ziggo_modem_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '675416a629f343c495449970e2ca37b5-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ziggo Modem Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ziggo_modem_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.2', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_consumed_interval-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.ziggo_modem_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '675416a629f343c495449970e2ca37b5-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ziggo Modem Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ziggo_modem_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_produced-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.ziggo_modem_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '675416a629f343c495449970e2ca37b5-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ziggo Modem Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ziggo_modem_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_produced_interval-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.ziggo_modem_electricity_produced_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_interval', + 'unique_id': '675416a629f343c495449970e2ca37b5-electricity_produced_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.ziggo_modem_electricity_produced_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ziggo Modem Electricity produced interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ziggo_modem_electricity_produced_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_battery-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': , + 'entity_id': 'sensor.zone_lisa_bios_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Lisa Bios Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_bios_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_setpoint-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': , + 'entity_id': 'sensor.zone_lisa_bios_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa Bios Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_bios_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_temperature-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': , + 'entity_id': 'sensor.zone_lisa_bios_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_bios_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa Bios Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_bios_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_battery-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': , + 'entity_id': 'sensor.zone_lisa_wk_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Lisa WK Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_wk_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_setpoint-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': , + 'entity_id': 'sensor.zone_lisa_wk_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa WK Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_wk_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_temperature-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': , + 'entity_id': 'sensor.zone_lisa_wk_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_lisa_wk_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Lisa WK Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_lisa_wk_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.9', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_battery-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': , + 'entity_id': 'sensor.zone_thermostat_jessie_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Thermostat Jessie Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.zone_thermostat_jessie_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_setpoint-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': , + 'entity_id': 'sensor.zone_thermostat_jessie_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Thermostat Jessie Setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_thermostat_jessie_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_temperature-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': , + 'entity_id': 'sensor.zone_thermostat_jessie_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_adam_sensor_snapshot[platforms0][sensor.zone_thermostat_jessie_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Zone Thermostat Jessie Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_thermostat_jessie_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.2', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_cooling_setpoint-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': , + 'entity_id': 'sensor.anna_cooling_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooling setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_setpoint', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-setpoint_high', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_cooling_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Anna Cooling setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.anna_cooling_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_heating_setpoint-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': , + 'entity_id': 'sensor.anna_heating_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating setpoint', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_setpoint', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-setpoint_low', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_heating_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Anna Heating setpoint', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.anna_heating_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_illuminance-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': , + 'entity_id': 'sensor.anna_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Anna Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.anna_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '86.0', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_temperature-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': , + 'entity_id': 'sensor.anna_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.anna_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Anna Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.anna_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.3', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_dhw_temperature-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': , + 'entity_id': 'sensor.opentherm_dhw_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-dhw_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm DHW temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46.3', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_intended_boiler_temperature-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': , + 'entity_id': 'sensor.opentherm_intended_boiler_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intended boiler temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intended_boiler_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-intended_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_intended_boiler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Intended boiler temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_intended_boiler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.0', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_modulation_level-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': , + 'entity_id': 'sensor.opentherm_modulation_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': 'Modulation level', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'modulation_level', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-modulation_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_modulation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Modulation level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.opentherm_modulation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_outdoor_air_temperature-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': , + 'entity_id': 'sensor.opentherm_outdoor_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor air temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_air_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-outdoor_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_outdoor_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Outdoor air temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_outdoor_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_return_temperature-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': , + 'entity_id': 'sensor.opentherm_return_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'return_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-return_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_return_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Return temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_return_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.1', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_water_pressure-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': , + 'entity_id': 'sensor.opentherm_water_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water pressure', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_pressure', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-water_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_water_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'OpenTherm Water pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_water_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.57', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_water_temperature-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': , + 'entity_id': 'sensor.opentherm_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.opentherm_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenTherm Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opentherm_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.1', + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.smile_anna_outdoor_temperature-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.smile_anna_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '015ae9ea3f964e668e490fa39da3870b-outdoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_anna_sensor_snapshot[platforms0-True-anna_heatpump_heating][sensor.smile_anna_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Smile Anna Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smile_anna_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.2', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_cumulative-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.p1_electricity_consumed_off_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed off-peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_off_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed off-peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70537.898', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_interval-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.p1_electricity_consumed_off_peak_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed off-peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_off_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed off-peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '314', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_point-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.p1_electricity_consumed_off_peak_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed off-peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_off_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_off_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity consumed off-peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5553', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_cumulative-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.p1_electricity_consumed_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '161328.641', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_interval-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.p1_electricity_consumed_peak_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_point-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.p1_electricity_consumed_peak_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_consumed_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_consumed_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity consumed peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_one_consumed-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.p1_electricity_phase_one_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity phase one consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_one_consumed', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_one_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_one_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase one consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_one_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1763', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_one_produced-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.p1_electricity_phase_one_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity phase one produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_one_produced', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_one_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_one_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase one produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_one_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_three_consumed-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.p1_electricity_phase_three_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity phase three consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_three_consumed', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_three_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_three_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase three consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_three_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2080', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_three_produced-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.p1_electricity_phase_three_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity phase three produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_three_produced', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_three_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_three_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase three produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_three_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_two_consumed-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.p1_electricity_phase_two_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity phase two consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_two_consumed', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_two_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_two_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase two consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_two_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1703', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_two_produced-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.p1_electricity_phase_two_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity phase two produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_two_produced', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_phase_two_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_phase_two_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase two produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_two_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_cumulative-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.p1_electricity_produced_off_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced off-peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_off_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced off-peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_interval-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.p1_electricity_produced_off_peak_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced off-peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_off_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced off-peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_point-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.p1_electricity_produced_off_peak_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced off-peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_off_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_off_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity produced off-peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_cumulative-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.p1_electricity_produced_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_interval-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.p1_electricity_produced_peak_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_point-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.p1_electricity_produced_peak_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-electricity_produced_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_electricity_produced_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity produced peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_gas_consumed_cumulative-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.p1_gas_consumed_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas consumed cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumed_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-gas_consumed_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_gas_consumed_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'P1 Gas consumed cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_gas_consumed_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16811.37', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_gas_consumed_interval-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.p1_gas_consumed_interval', + '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': 'Gas consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumed_interval', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-gas_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_gas_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'P1 Gas consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_gas_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.06', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_net_electricity_cumulative-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.p1_net_electricity_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net electricity cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'net_electricity_cumulative', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-net_electricity_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_net_electricity_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Net electricity cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_net_electricity_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231866.539', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_net_electricity_point-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.p1_net_electricity_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net electricity point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'net_electricity_point', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-net_electricity_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_net_electricity_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Net electricity point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_net_electricity_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5553', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_one-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.p1_voltage_phase_one', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase one', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_one', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-voltage_phase_one', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_one-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Voltage phase one', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_voltage_phase_one', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233.2', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_three-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.p1_voltage_phase_three', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase three', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_three', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-voltage_phase_three', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_three-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Voltage phase three', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_voltage_phase_three', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '234.7', + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_two-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.p1_voltage_phase_two', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase two', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_two', + 'unique_id': 'b82b6b3322484f2ea4e25e0bd5f3d61f-voltage_phase_two', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_3ph_dsmr_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][sensor.p1_voltage_phase_two-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Voltage phase two', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_voltage_phase_two', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '234.4', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_cumulative-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.p1_electricity_consumed_off_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed off-peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_off_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed off-peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17643.423', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_interval-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.p1_electricity_consumed_off_peak_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed off-peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_interval', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_off_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed off-peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_point-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.p1_electricity_consumed_off_peak_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed off-peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_off_peak_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_off_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_off_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity consumed off-peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_off_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '486', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_cumulative-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.p1_electricity_consumed_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13966.608', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_interval-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.p1_electricity_consumed_peak_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_interval', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity consumed peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_point-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.p1_electricity_consumed_peak_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_peak_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_consumed_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_consumed_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity consumed peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_consumed_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_phase_one_consumed-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.p1_electricity_phase_one_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity phase one consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_one_consumed', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_phase_one_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_phase_one_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase one consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_one_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '486', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_phase_one_produced-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.p1_electricity_phase_one_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity phase one produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_phase_one_produced', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_phase_one_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_phase_one_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity phase one produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_phase_one_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_cumulative-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.p1_electricity_produced_off_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced off-peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_off_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced off-peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_interval-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.p1_electricity_produced_off_peak_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced off-peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_interval', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_off_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced off-peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_point-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.p1_electricity_produced_off_peak_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced off-peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_off_peak_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_off_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_off_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity produced off-peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_off_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_cumulative-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.p1_electricity_produced_peak_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced peak cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_peak_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced peak cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_interval-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.p1_electricity_produced_peak_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced peak interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_interval', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_peak_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Electricity produced peak interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_point-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.p1_electricity_produced_peak_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced peak point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced_peak_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-electricity_produced_peak_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_electricity_produced_peak_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Electricity produced peak point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_electricity_produced_peak_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_net_electricity_cumulative-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.p1_net_electricity_cumulative', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net electricity cumulative', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'net_electricity_cumulative', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-net_electricity_cumulative', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_net_electricity_cumulative-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Net electricity cumulative', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_net_electricity_cumulative', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31610.031', + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_net_electricity_point-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.p1_net_electricity_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net electricity point', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'net_electricity_point', + 'unique_id': 'ba4de7613517478da82dd9b6abea36af-net_electricity_point', + 'unit_of_measurement': , + }) +# --- +# name: test_p1_dsmr_sensor_snapshot[platforms0-a455b61e52394b2db5081ce025a430f3-p1v4_442_single][sensor.p1_net_electricity_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Net electricity point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_net_electricity_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '486', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_consumed-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.boiler_1eb31_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '5871317346d045bc9f6b987ef25ee638-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Boiler (1EB31) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_1eb31_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.19', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_consumed_interval-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.boiler_1eb31_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '5871317346d045bc9f6b987ef25ee638-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Boiler (1EB31) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_1eb31_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_produced-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.boiler_1eb31_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '5871317346d045bc9f6b987ef25ee638-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.boiler_1eb31_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Boiler (1EB31) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_1eb31_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_consumed-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.droger_52559_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'cfe95cf3de1948c0b8955125bf754614-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Droger (52559) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_52559_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_consumed_interval-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.droger_52559_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'cfe95cf3de1948c0b8955125bf754614-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Droger (52559) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_52559_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_produced-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.droger_52559_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'cfe95cf3de1948c0b8955125bf754614-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.droger_52559_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Droger (52559) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_52559_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_consumed-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.koelkast_92c4a_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'e1c884e7dede431dadee09506ec4f859-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Koelkast (92C4A) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.koelkast_92c4a_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.5', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_consumed_interval-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.koelkast_92c4a_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'e1c884e7dede431dadee09506ec4f859-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Koelkast (92C4A) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.koelkast_92c4a_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_produced-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.koelkast_92c4a_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'e1c884e7dede431dadee09506ec4f859-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.koelkast_92c4a_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Koelkast (92C4A) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.koelkast_92c4a_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_consumed-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.vaatwasser_2a1ab_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': 'aac7b735042c4832ac9ff33aae4f453b-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Vaatwasser (2a1ab) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_consumed_interval-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.vaatwasser_2a1ab_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': 'aac7b735042c4832ac9ff33aae4f453b-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Vaatwasser (2a1ab) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.71', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_produced-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.vaatwasser_2a1ab_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': 'aac7b735042c4832ac9ff33aae4f453b-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.vaatwasser_2a1ab_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Vaatwasser (2a1ab) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vaatwasser_2a1ab_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_consumed-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.wasmachine_52ac1_electricity_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed', + 'unique_id': '059e4d03c7a34d278add5c7a4a781d19-electricity_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wasmachine (52AC1) Electricity consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wasmachine_52ac1_electricity_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_consumed_interval-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.wasmachine_52ac1_electricity_consumed_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumed interval', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_consumed_interval', + 'unique_id': '059e4d03c7a34d278add5c7a4a781d19-electricity_consumed_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_consumed_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wasmachine (52AC1) Electricity consumed interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wasmachine_52ac1_electricity_consumed_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_produced-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.wasmachine_52ac1_electricity_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity produced', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_produced', + 'unique_id': '059e4d03c7a34d278add5c7a4a781d19-electricity_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_stretch_sensor_snapshot[platforms0][sensor.wasmachine_52ac1_electricity_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wasmachine (52AC1) Electricity produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wasmachine_52ac1_electricity_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/plugwise/snapshots/test_switch.ambr b/tests/components/plugwise/snapshots/test_switch.ambr new file mode 100644 index 00000000000..e296b874210 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_switch.ambr @@ -0,0 +1,1264 @@ +# serializer version: 1 +# name: test_adam_switch_snapshot[platforms0][switch.cv_pomp_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.cv_pomp_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': '78d1126fc4c743db81b61c20e88342a7-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.cv_pomp_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'CV Pomp Relay', + }), + 'context': , + 'entity_id': 'switch.cv_pomp_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.fibaro_hc2_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.fibaro_hc2_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.fibaro_hc2_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fibaro HC2 Lock', + }), + 'context': , + 'entity_id': 'switch.fibaro_hc2_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.fibaro_hc2_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fibaro_hc2_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': 'a28f588dc4a049a483fd03a30361ad3a-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.fibaro_hc2_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Fibaro HC2 Relay', + }), + 'context': , + 'entity_id': 'switch.fibaro_hc2_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.nas_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.nas_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.nas_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NAS Lock', + }), + 'context': , + 'entity_id': 'switch.nas_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.nas_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.nas_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': 'cd0ddb54ef694e11ac18ed1cbce5dbbd-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.nas_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'NAS Relay', + }), + 'context': , + 'entity_id': 'switch.nas_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.nvr_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.nvr_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '02cf28bfec924855854c544690a609ef-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.nvr_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NVR Lock', + }), + 'context': , + 'entity_id': 'switch.nvr_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.nvr_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.nvr_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': '02cf28bfec924855854c544690a609ef-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.nvr_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'NVR Relay', + }), + 'context': , + 'entity_id': 'switch.nvr_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.playstation_smart_plug_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.playstation_smart_plug_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.playstation_smart_plug_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Playstation Smart Plug Lock', + }), + 'context': , + 'entity_id': 'switch.playstation_smart_plug_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.playstation_smart_plug_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.playstation_smart_plug_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': '21f2b542c49845e6bb416884c55778d6-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.playstation_smart_plug_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Playstation Smart Plug Relay', + }), + 'context': , + 'entity_id': 'switch.playstation_smart_plug_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.test_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': 'e8ef2a01ed3b4139a53bf749204fe6b4-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.test_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Relay', + }), + 'context': , + 'entity_id': 'switch.test_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.usg_smart_plug_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.usg_smart_plug_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '4a810418d5394b3f82727340b91ba740-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.usg_smart_plug_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'USG Smart Plug Lock', + }), + 'context': , + 'entity_id': 'switch.usg_smart_plug_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.usg_smart_plug_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.usg_smart_plug_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': '4a810418d5394b3f82727340b91ba740-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.usg_smart_plug_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'USG Smart Plug Relay', + }), + 'context': , + 'entity_id': 'switch.usg_smart_plug_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.ziggo_modem_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ziggo_modem_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '675416a629f343c495449970e2ca37b5-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.ziggo_modem_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ziggo Modem Lock', + }), + 'context': , + 'entity_id': 'switch.ziggo_modem_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.ziggo_modem_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ziggo_modem_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': '675416a629f343c495449970e2ca37b5-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_switch_snapshot[platforms0][switch.ziggo_modem_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Ziggo Modem Relay', + }), + 'context': , + 'entity_id': 'switch.ziggo_modem_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.boiler_1eb31_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.boiler_1eb31_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '5871317346d045bc9f6b987ef25ee638-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.boiler_1eb31_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Boiler (1EB31) Lock', + }), + 'context': , + 'entity_id': 'switch.boiler_1eb31_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.boiler_1eb31_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.boiler_1eb31_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': '5871317346d045bc9f6b987ef25ee638-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.boiler_1eb31_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Boiler (1EB31) Relay', + }), + 'context': , + 'entity_id': 'switch.boiler_1eb31_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.droger_52559_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.droger_52559_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'cfe95cf3de1948c0b8955125bf754614-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.droger_52559_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Droger (52559) Lock', + }), + 'context': , + 'entity_id': 'switch.droger_52559_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.droger_52559_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.droger_52559_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': 'cfe95cf3de1948c0b8955125bf754614-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.droger_52559_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Droger (52559) Relay', + }), + 'context': , + 'entity_id': 'switch.droger_52559_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.koelkast_92c4a_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.koelkast_92c4a_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'e1c884e7dede431dadee09506ec4f859-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.koelkast_92c4a_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Koelkast (92C4A) Lock', + }), + 'context': , + 'entity_id': 'switch.koelkast_92c4a_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.koelkast_92c4a_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koelkast_92c4a_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': 'e1c884e7dede431dadee09506ec4f859-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.koelkast_92c4a_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Koelkast (92C4A) Relay', + }), + 'context': , + 'entity_id': 'switch.koelkast_92c4a_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.schakel_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.schakel_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': 'd03738edfcc947f7b8f4573571d90d2d-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.schakel_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Schakel Relay', + }), + 'context': , + 'entity_id': 'switch.schakel_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.stroomvreters_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.stroomvreters_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': 'd950b314e9d8499f968e6db8d82ef78c-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.stroomvreters_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Stroomvreters Relay', + }), + 'context': , + 'entity_id': 'switch.stroomvreters_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.vaatwasser_2a1ab_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.vaatwasser_2a1ab_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'aac7b735042c4832ac9ff33aae4f453b-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.vaatwasser_2a1ab_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vaatwasser (2a1ab) Lock', + }), + 'context': , + 'entity_id': 'switch.vaatwasser_2a1ab_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.vaatwasser_2a1ab_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.vaatwasser_2a1ab_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': 'aac7b735042c4832ac9ff33aae4f453b-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.vaatwasser_2a1ab_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Vaatwasser (2a1ab) Relay', + }), + 'context': , + 'entity_id': 'switch.vaatwasser_2a1ab_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.wasmachine_52ac1_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wasmachine_52ac1_lock', + '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': 'Lock', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '059e4d03c7a34d278add5c7a4a781d19-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.wasmachine_52ac1_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Wasmachine (52AC1) Lock', + }), + 'context': , + 'entity_id': 'switch.wasmachine_52ac1_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.wasmachine_52ac1_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.wasmachine_52ac1_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay', + 'unique_id': '059e4d03c7a34d278add5c7a4a781d19-relay', + 'unit_of_measurement': None, + }) +# --- +# name: test_stretch_switch_snapshot[platforms0][switch.wasmachine_52ac1_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Wasmachine (52AC1) Relay', + }), + 'context': , + 'entity_id': 'switch.wasmachine_52ac1_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index b8554f9a5cc..084eaa63d28 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -6,180 +6,46 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from plugwise.exceptions import PlugwiseError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, PRESET_AWAY, - PRESET_HOME, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, HVACAction, HVACMode, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( "homeassistant.components.plugwise.coordinator.Smile.async_update" ) -async def test_adam_climate_entity_attributes( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.woonkamer") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] - assert ATTR_PRESET_MODES in state.attributes - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.9 - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 17 - assert state.attributes[ATTR_TEMPERATURE] == 21.5 - assert state.attributes[ATTR_MIN_TEMP] == 0.0 - assert state.attributes[ATTR_MAX_TEMP] == 35.0 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - state = hass.states.get("climate.jessie") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] - assert ATTR_PRESET_MODES in state.attributes - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - assert state.attributes[ATTR_PRESET_MODE] == "asleep" - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 17.2 - assert state.attributes[ATTR_TEMPERATURE] == 15.0 - assert state.attributes[ATTR_MIN_TEMP] == 0.0 - assert state.attributes[ATTR_MAX_TEMP] == 35.0 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - -@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [False], indirect=True) -async def test_adam_2_climate_entity_attributes( +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_climate_snapshot( hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.PREHEATING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - state = hass.states.get("climate.bathroom") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - -@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_adam_3_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.COOL - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - ] - data = mock_smile_adam_heat_cool.async_update.return_value - data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" - data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True - with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - data = mock_smile_adam_heat_cool.async_update.return_value - data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" - data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False - with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.COOL - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - ] - - -async def test_adam_climate_adjust_negative_testing( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test PlugwiseError exception.""" - mock_smile_adam.set_temperature.side_effect = PlugwiseError - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, - blocking=True, - ) + """Test Adam climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_climate_entity_climate_changes( @@ -257,6 +123,95 @@ async def test_adam_climate_entity_climate_changes( ) +async def test_adam_climate_adjust_negative_testing( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test PlugwiseError exception.""" + mock_smile_adam.set_temperature.side_effect = PlugwiseError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, + blocking=True, + ) + + +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_2_climate_snapshot( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam 2 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +async def test_adam_3_climate_entity_attributes( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test creation of adam climate device environment.""" + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + ] + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] + + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + ] + + async def test_adam_climate_off_mode_change( hass: HomeAssistant, mock_smile_adam_jip: MagicMock, @@ -313,68 +268,17 @@ async def test_adam_climate_off_mode_change( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_climate_entity_attributes( +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_climate_snapshot( hass: HomeAssistant, mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT_COOL] - - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 19.3 - assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 - assert state.attributes[ATTR_MIN_TEMP] == 4 - assert state.attributes[ATTR_MAX_TEMP] == 30 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - -@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_2_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, -) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.AUTO, - HVACMode.HEAT_COOL, - ] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 - - -@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_3_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, -) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.AUTO, - HVACMode.HEAT_COOL, - ] + """Test Anna climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @@ -446,3 +350,33 @@ async def test_anna_climate_entity_climate_changes( state = hass.states.get("climate.anna") assert state.state == HVACMode.HEAT_COOL assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] + + +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_2_climate_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna 2 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_3_climate_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna 3 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index 4ae461d96c8..d89a0148784 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, @@ -12,81 +13,22 @@ from homeassistant.components.number import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_number_entities( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test creation of a number.""" - state = hass.states.get("number.opentherm_maximum_boiler_temperature_setpoint") - assert state - assert float(state.state) == 60.0 - - -@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_max_boiler_temp_change( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test changing of number entities.""" - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.opentherm_maximum_boiler_temperature_setpoint", - ATTR_VALUE: 65, - }, - blocking=True, - ) - - assert mock_smile_anna.set_number.call_count == 1 - mock_smile_anna.set_number.assert_called_with( - "1cbf783bb11e4a7c8a6843dee3a86927", "maximum_boiler_temperature", 65.0 - ) - - -@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [False], indirect=True) -async def test_adam_dhw_setpoint_change( +@pytest.mark.parametrize("platforms", [(NUMBER_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_number_entities( hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test changing of number entities.""" - state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") - assert state - assert float(state.state) == 60.0 - - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.opentherm_domestic_hot_water_setpoint", - ATTR_VALUE: 55, - }, - blocking=True, - ) - - assert mock_smile_adam_heat_cool.set_number.call_count == 1 - mock_smile_adam_heat_cool.set_number.assert_called_with( - "056ee145a816487eaa69243c3280f8bf", "max_dhw_temperature", 55.0 - ) - - -async def test_adam_temperature_offset( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test creation of the temperature_offset number.""" - state = hass.states.get("number.zone_thermostat_jessie_temperature_offset") - assert state - assert float(state.state) == 0.0 - assert state.attributes.get("min") == -2.0 - assert state.attributes.get("max") == 2.0 - assert state.attributes.get("step") == 0.1 + """Test Adam number snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_temperature_offset_change( @@ -123,3 +65,68 @@ async def test_adam_temperature_offset_out_of_bounds_change( }, blocking=True, ) + + +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +async def test_adam_dhw_setpoint_change( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test changing of number entities.""" + state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") + assert state + assert float(state.state) == 60.0 + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.opentherm_domestic_hot_water_setpoint", + ATTR_VALUE: 55, + }, + blocking=True, + ) + + assert mock_smile_adam_heat_cool.set_number.call_count == 1 + mock_smile_adam_heat_cool.set_number.assert_called_with( + "056ee145a816487eaa69243c3280f8bf", "max_dhw_temperature", 55.0 + ) + + +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(NUMBER_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_number_entities( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna number snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +async def test_anna_max_boiler_temp_change( + hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of number entities.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.opentherm_maximum_boiler_temperature_setpoint", + ATTR_VALUE: 65, + }, + blocking=True, + ) + + assert mock_smile_anna.set_number.call_count == 1 + mock_smile_anna.set_number.assert_called_with( + "1cbf783bb11e4a7c8a6843dee3a86927", "maximum_boiler_temperature", 65.0 + ) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f6c4205b756..91ef44049fd 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, @@ -12,18 +13,22 @@ from homeassistant.components.select import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_select_entities( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test a thermostat Select.""" - - state = hass.states.get("select.woonkamer_thermostat_schedule") - assert state - assert state.state == "GF7 Woonkamer" + """Test Adam select snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_change_select_entity( @@ -50,6 +55,21 @@ async def test_adam_change_select_entity( ) +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_2_select_entities( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam with cooling select snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_regulation_mode( @@ -57,17 +77,10 @@ async def test_adam_select_regulation_mode( mock_smile_adam_heat_cool: MagicMock, init_integration: MockConfigEntry, ) -> None: - """Test a regulation_mode select. + """Test changing the regulation_mode select. Also tests a change in climate _previous mode. """ - - state = hass.states.get("select.adam_gateway_mode") - assert state - assert state.state == "full" - state = hass.states.get("select.adam_regulation_mode") - assert state - assert state.state == "cooling" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -97,10 +110,10 @@ async def test_legacy_anna_select_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_adam_select_unavailable_regulation_mode( +async def test_anna_select_unavailable_schedule_mode( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test a regulation_mode non-available preset.""" + """Fail-test an Anna thermostat_schedule select option.""" with pytest.raises(ServiceValidationError, match="valid options"): await hass.services.async_call( @@ -108,7 +121,7 @@ async def test_adam_select_unavailable_regulation_mode( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.anna_thermostat_schedule", - ATTR_OPTION: "freezing", + ATTR_OPTION: "Winter", }, blocking=True, ) diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index c6c6c6cc284..1538c8e691f 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -3,49 +3,35 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_adam_climate_sensor_entities( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_sensor_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of climate related sensor entities.""" - state = hass.states.get("sensor.adam_outdoor_temperature") - assert state - assert float(state.state) == 7.81 - - state = hass.states.get("sensor.cv_pomp_electricity_consumed") - assert state - assert float(state.state) == 35.6 - - state = hass.states.get("sensor.onoff_water_temperature") - assert state - assert float(state.state) == 70.0 - - state = hass.states.get("sensor.cv_pomp_electricity_consumed_interval") - assert state - assert float(state.state) == 7.37 - - await async_update_entity(hass, "sensor.zone_lisa_wk_battery") - - state = hass.states.get("sensor.zone_lisa_wk_battery") - assert state - assert int(state.state) == 34 + """Test Adam sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) -async def test_adam_climate_sensor_entity_2( +async def test_adam_climate_sensor_humidity( hass: HomeAssistant, mock_smile_adam_jip: MagicMock, init_integration: MockConfigEntry, ) -> None: - """Test creation of climate related sensor entities.""" + """Test creation of climate related humidity sensor entity.""" state = hass.states.get("sensor.woonkamer_humidity") assert state assert float(state.state) == 56.2 @@ -96,83 +82,51 @@ async def test_unique_id_migration_humidity( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_as_smt_climate_sensor_entities( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_sensor_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of climate related sensor entities.""" - state = hass.states.get("sensor.opentherm_outdoor_air_temperature") - assert state - assert float(state.state) == 3.0 - - state = hass.states.get("sensor.opentherm_water_temperature") - assert state - assert float(state.state) == 29.1 - - state = hass.states.get("sensor.opentherm_dhw_temperature") - assert state - assert float(state.state) == 46.3 - - state = hass.states.get("sensor.anna_illuminance") - assert state - assert float(state.state) == 86.0 + """Test Anna sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True ) -async def test_p1_dsmr_sensor_entities( - hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_p1_dsmr_sensor_snapshot( + hass: HomeAssistant, + mock_smile_p1: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of power related sensor entities.""" - state = hass.states.get("sensor.p1_net_electricity_point") - assert state - assert int(state.state) == 486 - - state = hass.states.get("sensor.p1_electricity_consumed_off_peak_cumulative") - assert state - assert float(state.state) == 17643.423 - - state = hass.states.get("sensor.p1_electricity_produced_peak_point") - assert state - assert int(state.state) == 0 - - state = hass.states.get("sensor.p1_electricity_consumed_peak_cumulative") - assert state - assert float(state.state) == 13966.608 - - state = hass.states.get("sensor.p1_gas_consumed_cumulative") - assert not state + """Test P1 1-phase sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True ) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_p1_3ph_dsmr_sensor_entities( +async def test_p1_3ph_dsmr_sensor_snapshot( hass: HomeAssistant, - entity_registry: er.EntityRegistry, mock_smile_p1: MagicMock, - init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of power related sensor entities.""" - state = hass.states.get("sensor.p1_electricity_phase_one_consumed") - assert state - assert int(state.state) == 1763 - - state = hass.states.get("sensor.p1_electricity_phase_two_consumed") - assert state - assert int(state.state) == 1703 - - state = hass.states.get("sensor.p1_electricity_phase_three_consumed") - assert state - assert int(state.state) == 2080 - - # Default disabled sensor test - state = hass.states.get("sensor.p1_voltage_phase_one") - assert state - assert float(state.state) == 233.2 + """Test P1 3-phase sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @@ -186,18 +140,29 @@ async def test_p1_3ph_dsmr_sensor_disabled_entities( init_integration: MockConfigEntry, ) -> None: """Test disabled power related sensor entities intent.""" - state = hass.states.get("sensor.p1_voltage_phase_one") + entity_id = "sensor.p1_voltage_phase_one" + state = hass.states.get(entity_id) assert not state + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done() -async def test_stretch_sensor_entities( - hass: HomeAssistant, mock_stretch: MagicMock, init_integration: MockConfigEntry + await hass.config_entries.async_reload(init_integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.p1_voltage_phase_one") + assert state + assert float(state.state) == 233.2 + + +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_stretch_sensor_snapshot( + hass: HomeAssistant, + mock_stretch: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of power related sensor entities.""" - state = hass.states.get("sensor.koelkast_92c4a_electricity_consumed") - assert state - assert float(state.state) == 50.5 - - state = hass.states.get("sensor.droger_52559_electricity_consumed_interval") - assert state - assert float(state.state) == 0.0 + """Test Stretch sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index 003c47ed1f4..f04cf92c0da 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from plugwise.exceptions import PlugwiseException import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -19,53 +20,20 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_adam_climate_switch_entities( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(SWITCH_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_switch_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of climate related switch entities.""" - state = hass.states.get("switch.cv_pomp_relay") - assert state - assert state.state == STATE_ON - - state = hass.states.get("switch.fibaro_hc2_relay") - assert state - assert state.state == STATE_ON - - -async def test_adam_climate_switch_negative_testing( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test exceptions of climate related switch entities.""" - mock_smile_adam.set_switch_state.side_effect = PlugwiseException - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.cv_pomp_relay"}, - blocking=True, - ) - - assert mock_smile_adam.set_switch_state.call_count == 1 - mock_smile_adam.set_switch_state.assert_called_with( - "78d1126fc4c743db81b61c20e88342a7", None, "relay", STATE_OFF - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.fibaro_hc2_relay"}, - blocking=True, - ) - - assert mock_smile_adam.set_switch_state.call_count == 2 - mock_smile_adam.set_switch_state.assert_called_with( - "a28f588dc4a049a483fd03a30361ad3a", None, "relay", STATE_ON - ) + """Test Adam switch snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_climate_switch_changes( @@ -109,17 +77,50 @@ async def test_adam_climate_switch_changes( ) -async def test_stretch_switch_entities( - hass: HomeAssistant, mock_stretch: MagicMock, init_integration: MockConfigEntry +async def test_adam_climate_switch_negative_testing( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test creation of climate related switch entities.""" - state = hass.states.get("switch.koelkast_92c4a_relay") - assert state - assert state.state == STATE_ON + """Test exceptions of climate related switch entities.""" + mock_smile_adam.set_switch_state.side_effect = PlugwiseException - state = hass.states.get("switch.droger_52559_relay") - assert state - assert state.state == STATE_ON + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.cv_pomp_relay"}, + blocking=True, + ) + + assert mock_smile_adam.set_switch_state.call_count == 1 + mock_smile_adam.set_switch_state.assert_called_with( + "78d1126fc4c743db81b61c20e88342a7", None, "relay", STATE_OFF + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.fibaro_hc2_relay"}, + blocking=True, + ) + + assert mock_smile_adam.set_switch_state.call_count == 2 + mock_smile_adam.set_switch_state.assert_called_with( + "a28f588dc4a049a483fd03a30361ad3a", None, "relay", STATE_ON + ) + + +@pytest.mark.parametrize("platforms", [(SWITCH_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_stretch_switch_snapshot( + hass: HomeAssistant, + mock_stretch: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Stretch switch snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_stretch_switch_changes( diff --git a/tests/components/plum_lightpad/test_config_flow.py b/tests/components/plum_lightpad/test_config_flow.py deleted file mode 100644 index ca7c110c963..00000000000 --- a/tests/components/plum_lightpad/test_config_flow.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Test the Plum Lightpad config flow.""" - -from unittest.mock import patch - -from requests.exceptions import ConnectTimeout - -from homeassistant import config_entries -from homeassistant.components.plum_lightpad.const import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -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["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch("homeassistant.components.plum_lightpad.utils.Plum.loadCloudData"), - patch( - "homeassistant.components.plum_lightpad.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-plum-username", "password": "test-plum-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-plum-username" - assert result2["data"] == { - "username": "test-plum-username", - "password": "test-plum-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData", - side_effect=ConnectTimeout, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-plum-username", "password": "test-plum-password"}, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_one_entry_per_email_allowed(hass: HomeAssistant) -> None: - """Test that only one entry allowed per Plum cloud email address.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="test-plum-username", - data={"username": "test-plum-username", "password": "test-plum-password"}, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch("homeassistant.components.plum_lightpad.utils.Plum.loadCloudData"), - patch( - "homeassistant.components.plum_lightpad.async_setup_entry" - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-plum-username", "password": "test-plum-password"}, - ) - - assert result2["type"] is FlowResultType.ABORT - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/plum_lightpad/test_init.py b/tests/components/plum_lightpad/test_init.py index c34ecfd8deb..09a140016a2 100644 --- a/tests/components/plum_lightpad/test_init.py +++ b/tests/components/plum_lightpad/test_init.py @@ -1,91 +1,51 @@ """Tests for the Plum Lightpad config flow.""" -from unittest.mock import Mock, patch - -from aiohttp import ContentTypeError -from requests.exceptions import HTTPError - -from homeassistant.components.plum_lightpad.const import DOMAIN +from homeassistant.components.plum_lightpad import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers import issue_registry as ir from tests.common import MockConfigEntry -async def test_async_setup_no_domain_config(hass: HomeAssistant) -> None: - """Test setup without configuration is noop.""" - result = await async_setup_component(hass, DOMAIN, {}) +async def test_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test Plum Lightpad repair issue.""" - assert result is True - assert DOMAIN not in hass.data - - -async def test_async_setup_entry_sets_up_light(hass: HomeAssistant) -> None: - """Test that configuring entry sets up light domain.""" - config_entry = MockConfigEntry( + config_entry_1 = MockConfigEntry( + title="Example 1", domain=DOMAIN, - data={"username": "test-plum-username", "password": "test-plum-password"}, ) - config_entry.add_to_hass(hass) + 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 - with ( - patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" - ) as mock_loadCloudData, - patch( - "homeassistant.components.plum_lightpad.light.async_setup_entry" - ) as mock_light_async_setup_entry, - ): - result = await hass.config_entries.async_setup(config_entry.entry_id) - assert result is True - - await hass.async_block_till_done() - - assert len(mock_loadCloudData.mock_calls) == 1 - assert len(mock_light_async_setup_entry.mock_calls) == 1 - - -async def test_async_setup_entry_handles_auth_error(hass: HomeAssistant) -> None: - """Test that configuring entry handles Plum Cloud authentication error.""" - config_entry = MockConfigEntry( + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", domain=DOMAIN, - data={"username": "test-plum-username", "password": "test-plum-password"}, ) - config_entry.add_to_hass(hass) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData", - side_effect=ContentTypeError(Mock(), None), - ), - patch( - "homeassistant.components.plum_lightpad.light.async_setup_entry" - ) as mock_light_async_setup_entry, - ): - result = await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - assert result is False - assert len(mock_light_async_setup_entry.mock_calls) == 0 + # 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) -async def test_async_setup_entry_handles_http_error(hass: HomeAssistant) -> None: - """Test that configuring entry handles HTTP error.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "test-plum-username", "password": "test-plum-password"}, - ) - config_entry.add_to_hass(hass) + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData", - side_effect=HTTPError, - ), - patch( - "homeassistant.components.plum_lightpad.light.async_setup_entry" - ) as mock_light_async_setup_entry, - ): - result = await hass.config_entries.async_setup(config_entry.entry_id) - - assert result is False - assert len(mock_light_async_setup_entry.mock_calls) == 0 + 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 diff --git a/tests/components/pooldose/__init__.py b/tests/components/pooldose/__init__.py new file mode 100644 index 00000000000..42a7b6bf8cc --- /dev/null +++ b/tests/components/pooldose/__init__.py @@ -0,0 +1 @@ +"""Tests for the Pooldose integration.""" diff --git a/tests/components/pooldose/conftest.py b/tests/components/pooldose/conftest.py new file mode 100644 index 00000000000..f7a6ddc6d09 --- /dev/null +++ b/tests/components/pooldose/conftest.py @@ -0,0 +1,85 @@ +"""Test fixtures for the Seko PoolDose integration.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from pooldose.request_status import RequestStatus +import pytest + +from homeassistant.components.pooldose.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_load_json_object_fixture, + load_json_object_fixture, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.pooldose.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def device_info(hass: HomeAssistant) -> dict[str, Any]: + """Return the device info from the fixture.""" + return await async_load_json_object_fixture(hass, "deviceinfo.json", DOMAIN) + + +@pytest.fixture(autouse=True) +def mock_pooldose_client(device_info: dict[str, Any]) -> Generator[MagicMock]: + """Mock a PooldoseClient for end-to-end testing.""" + with ( + patch( + "homeassistant.components.pooldose.config_flow.PooldoseClient", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.pooldose.PooldoseClient", new=mock_client_class + ), + ): + client = mock_client_class.return_value + client.device_info = device_info + + # Setup client methods with realistic responses + client.connect.return_value = RequestStatus.SUCCESS + client.check_apiversion_supported.return_value = (RequestStatus.SUCCESS, {}) + + # Load instant values from fixture + instant_values_data = load_json_object_fixture("instantvalues.json", DOMAIN) + client.instant_values_structured.return_value = ( + RequestStatus.SUCCESS, + instant_values_data, + ) + + client.is_connected = True + yield client + + +@pytest.fixture +def mock_config_entry(device_info: dict[str, Any]) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Pool Device", + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id=device_info["SERIAL_NUMBER"], + entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", + ) + + +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> 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() + return mock_config_entry diff --git a/tests/components/pooldose/fixtures/deviceinfo.json b/tests/components/pooldose/fixtures/deviceinfo.json new file mode 100644 index 00000000000..69ac3ba0a0a --- /dev/null +++ b/tests/components/pooldose/fixtures/deviceinfo.json @@ -0,0 +1,15 @@ +{ + "NAME": "Pool Device", + "SERIAL_NUMBER": "TEST123456789", + "DEVICE_ID": "TEST123456789_DEVICE", + "MODEL": "POOL DOSE", + "MODEL_ID": "PDPR1H1HAW100", + "OWNERID": "GBL00001ENDUSERS", + "GROUPNAME": "Pool Device", + "FW_VERSION": "1.30", + "SW_VERSION": "2.10", + "API_VERSION": "v1/", + "FW_CODE": "539187", + "MAC": "", + "IP": "192.168.1.100" +} diff --git a/tests/components/pooldose/fixtures/instantvalues.json b/tests/components/pooldose/fixtures/instantvalues.json new file mode 100644 index 00000000000..8e89e60c9b4 --- /dev/null +++ b/tests/components/pooldose/fixtures/instantvalues.json @@ -0,0 +1,126 @@ +{ + "sensor": { + "temperature": { + "value": 25, + "unit": "°C" + }, + "ph": { + "value": 6.8, + "unit": null + }, + "orp": { + "value": 718, + "unit": "mV" + }, + "ph_type_dosing": { + "value": "alcalyne", + "unit": null + }, + "peristaltic_ph_dosing": { + "value": "proportional", + "unit": null + }, + "ofa_ph_value": { + "value": 0, + "unit": "min" + }, + "orp_type_dosing": { + "value": "low", + "unit": null + }, + "peristaltic_orp_dosing": { + "value": "proportional", + "unit": null + }, + "ofa_orp_value": { + "value": 0, + "unit": "min" + }, + "ph_calibration_type": { + "value": "2_points", + "unit": null + }, + "ph_calibration_offset": { + "value": 8, + "unit": "mV" + }, + "ph_calibration_slope": { + "value": 57.34, + "unit": "mV" + }, + "orp_calibration_type": { + "value": "1_point", + "unit": null + }, + "orp_calibration_offset": { + "value": 0, + "unit": "mV" + }, + "orp_calibration_slope": { + "value": 0.96, + "unit": "mV" + } + }, + "binary_sensor": { + "pump_running": { + "value": true + }, + "ph_level_ok": { + "value": false + }, + "orp_level_ok": { + "value": false + }, + "flow_rate_ok": { + "value": false + }, + "alarm_relay": { + "value": true + }, + "relay_aux1_ph": { + "value": false + }, + "relay_aux2_orpcl": { + "value": false + } + }, + "number": { + "ph_target": { + "value": 6.5, + "unit": null, + "min": 6, + "max": 8, + "step": 0.1 + }, + "orp_target": { + "value": 680, + "unit": "mV", + "min": 400, + "max": 850, + "step": 1 + }, + "cl_target": { + "value": 1, + "unit": "ppm", + "min": 0, + "max": 65535, + "step": 0.01 + } + }, + "switch": { + "stop_pool_dosing": { + "value": false + }, + "pump_detection": { + "value": true + }, + "frequency_input": { + "value": false + } + }, + "select": { + "water_meter_unit": { + "value": "m³" + } + } +} diff --git a/tests/components/pooldose/snapshots/test_init.ambr b/tests/components/pooldose/snapshots/test_init.ambr new file mode 100644 index 00000000000..b4a76f55c83 --- /dev/null +++ b/tests/components/pooldose/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_devices + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://192.168.1.100/index.html', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '539187', + 'id': , + 'identifiers': set({ + tuple( + 'pooldose', + 'TEST123456789', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'SEKO', + 'model': 'POOL DOSE', + 'model_id': 'PDPR1H1HAW100', + 'name': 'Pool Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'TEST123456789', + 'sw_version': '1.30 (SW v2.10, API v1)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/pooldose/snapshots/test_sensor.ambr b/tests/components/pooldose/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..510f1b7cdf9 --- /dev/null +++ b/tests/components/pooldose/snapshots/test_sensor.ambr @@ -0,0 +1,834 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.pool_device_orp-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': None, + 'entity_id': 'sensor.pool_device_orp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp', + 'unique_id': 'TEST123456789_orp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pool Device ORP', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_orp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '718', + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_calibration_offset-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.pool_device_orp_calibration_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP calibration offset', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_calibration_offset', + 'unique_id': 'TEST123456789_orp_calibration_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_calibration_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pool Device ORP calibration offset', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_orp_calibration_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_calibration_slope-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.pool_device_orp_calibration_slope', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP calibration slope', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_calibration_slope', + 'unique_id': 'TEST123456789_orp_calibration_slope', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_calibration_slope-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pool Device ORP calibration slope', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_orp_calibration_slope', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.96', + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_calibration_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'reference', + '1_point', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_orp_calibration_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP calibration type', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_calibration_type', + 'unique_id': 'TEST123456789_orp_calibration_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_calibration_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Pool Device ORP calibration type', + 'options': list([ + 'off', + 'reference', + '1_point', + ]), + }), + 'context': , + 'entity_id': 'sensor.pool_device_orp_calibration_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1_point', + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_dosing_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_orp_dosing_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP dosing type', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_type_dosing', + 'unique_id': 'TEST123456789_orp_type_dosing', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_dosing_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Pool Device ORP dosing type', + 'options': list([ + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.pool_device_orp_dosing_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_overfeed_alert_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_orp_overfeed_alert_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP overfeed alert time', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ofa_orp_value', + 'unique_id': 'TEST123456789_ofa_orp_value', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_overfeed_alert_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pool Device ORP overfeed alert time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_orp_overfeed_alert_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_peristaltic_dosing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_orp_peristaltic_dosing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP peristaltic dosing', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'peristaltic_orp_dosing', + 'unique_id': 'TEST123456789_peristaltic_orp_dosing', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_orp_peristaltic_dosing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Pool Device ORP peristaltic dosing', + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'sensor.pool_device_orp_peristaltic_dosing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'proportional', + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph-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': None, + 'entity_id': 'sensor.pool_device_ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'TEST123456789_ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'ph', + 'friendly_name': 'Pool Device pH', + }), + 'context': , + 'entity_id': 'sensor.pool_device_ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.8', + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_calibration_offset-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.pool_device_ph_calibration_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH calibration offset', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_calibration_offset', + 'unique_id': 'TEST123456789_ph_calibration_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_calibration_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pool Device pH calibration offset', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_ph_calibration_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_calibration_slope-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.pool_device_ph_calibration_slope', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH calibration slope', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_calibration_slope', + 'unique_id': 'TEST123456789_ph_calibration_slope', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_calibration_slope-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pool Device pH calibration slope', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_ph_calibration_slope', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57.34', + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_calibration_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'reference', + '1_point', + '2_points', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_ph_calibration_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH calibration type', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_calibration_type', + 'unique_id': 'TEST123456789_ph_calibration_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_calibration_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Pool Device pH calibration type', + 'options': list([ + 'off', + 'reference', + '1_point', + '2_points', + ]), + }), + 'context': , + 'entity_id': 'sensor.pool_device_ph_calibration_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2_points', + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_dosing_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'alcalyne', + 'acid', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_ph_dosing_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH dosing type', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_type_dosing', + 'unique_id': 'TEST123456789_ph_type_dosing', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_dosing_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Pool Device pH dosing type', + 'options': list([ + 'alcalyne', + 'acid', + ]), + }), + 'context': , + 'entity_id': 'sensor.pool_device_ph_dosing_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'alcalyne', + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_overfeed_alert_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_ph_overfeed_alert_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH overfeed alert time', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ofa_ph_value', + 'unique_id': 'TEST123456789_ofa_ph_value', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_overfeed_alert_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pool Device pH overfeed alert time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_ph_overfeed_alert_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_peristaltic_dosing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_ph_peristaltic_dosing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH peristaltic dosing', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'peristaltic_ph_dosing', + 'unique_id': 'TEST123456789_peristaltic_ph_dosing', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_ph_peristaltic_dosing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Pool Device pH peristaltic dosing', + 'options': list([ + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'sensor.pool_device_ph_peristaltic_dosing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'proportional', + }) +# --- +# name: test_all_sensors[sensor.pool_device_temperature-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': None, + 'entity_id': 'sensor.pool_device_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'TEST123456789_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pool Device Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- diff --git a/tests/components/pooldose/test_config_flow.py b/tests/components/pooldose/test_config_flow.py new file mode 100644 index 00000000000..354808c51d3 --- /dev/null +++ b/tests/components/pooldose/test_config_flow.py @@ -0,0 +1,428 @@ +"""Test the PoolDose config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.pooldose.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .conftest import RequestStatus + +from tests.common import MockConfigEntry + + +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "PoolDose TEST123456789" + assert result["data"] == {CONF_HOST: "192.168.1.100"} + assert result["result"].unique_id == "TEST123456789" + + +async def test_device_unreachable( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that the form shows error when device is unreachable.""" + mock_pooldose_client.is_connected = False + mock_pooldose_client.connect.return_value = RequestStatus.HOST_UNREACHABLE + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_pooldose_client.is_connected = True + mock_pooldose_client.connect.return_value = RequestStatus.SUCCESS + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_api_version_unsupported( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that the form shows error when API version is unsupported.""" + mock_pooldose_client.check_apiversion_supported.return_value = ( + RequestStatus.API_VERSION_UNSUPPORTED, + {"api_version_is": "v0.9", "api_version_should": "v1.0"}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "api_not_supported"} + + mock_pooldose_client.is_connected = True + mock_pooldose_client.check_apiversion_supported.return_value = ( + RequestStatus.SUCCESS, + {}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_no_device_info( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + device_info: dict[str, Any], +) -> None: + """Test that the form shows error when device_info is None.""" + mock_pooldose_client.device_info = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_device_info"} + + mock_pooldose_client.device_info = device_info + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("client_status", "expected_error"), + [ + (RequestStatus.HOST_UNREACHABLE, "cannot_connect"), + (RequestStatus.PARAMS_FETCH_FAILED, "params_fetch_failed"), + (RequestStatus.UNKNOWN_ERROR, "cannot_connect"), + ], +) +async def test_connection_errors( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + client_status: str, + expected_error: str, +) -> None: + """Test that the form shows appropriate errors for various connection issues.""" + mock_pooldose_client.connect.return_value = client_status + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_pooldose_client.connect.return_value = RequestStatus.SUCCESS + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_api_no_data( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that the form shows error when API returns NO_DATA.""" + mock_pooldose_client.check_apiversion_supported.return_value = ( + RequestStatus.NO_DATA, + {}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "api_not_set"} + + mock_pooldose_client.check_apiversion_supported.return_value = ( + RequestStatus.SUCCESS, + {}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_no_serial_number( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + device_info: dict[str, Any], +) -> None: + """Test that the form shows error when device_info has no serial number.""" + mock_pooldose_client.device_info = {"NAME": "Pool Device", "MODEL": "POOL DOSE"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_serial_number"} + + mock_pooldose_client.device_info = device_info + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry_aborts( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that the flow aborts if the device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.100"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full DHCP config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "PoolDose TEST123456789" + assert result["data"][CONF_HOST] == "192.168.0.123" + assert result["data"][CONF_MAC] == "a4e57caabbcc" + assert result["result"].unique_id == "TEST123456789" + + +async def test_dhcp_no_serial_number( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that the DHCP flow aborts if no serial number is found.""" + mock_pooldose_client.device_info = {"NAME": "Pool Device", "MODEL": "POOL DOSE"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_serial_number" + + +@pytest.mark.parametrize( + ("client_status"), + [ + (RequestStatus.HOST_UNREACHABLE), + (RequestStatus.PARAMS_FETCH_FAILED), + (RequestStatus.UNKNOWN_ERROR), + ], +) +async def test_dhcp_connection_errors( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + client_status: str, +) -> None: + """Test that the DHCP flow aborts on connection errors.""" + mock_pooldose_client.connect.return_value = client_status + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_serial_number" + + +@pytest.mark.parametrize( + "api_status", + [ + RequestStatus.NO_DATA, + RequestStatus.API_VERSION_UNSUPPORTED, + ], +) +async def test_dhcp_api_errors( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_setup_entry: AsyncMock, + api_status: str, +) -> None: + """Test that the DHCP flow aborts on API errors.""" + mock_pooldose_client.check_apiversion_supported.return_value = (api_status, {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_serial_number" + + +async def test_dhcp_updates_host( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_setup_entry: AsyncMock +) -> None: + """Test that DHCP discovery updates the host if it has changed.""" + mock_config_entry.add_to_hass(hass) + + # Verify initial host IP + assert mock_config_entry.data[CONF_HOST] == "192.168.1.100" + + # Simulate DHCP discovery event with different IP + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + + # Verify flow aborts as device is already configured + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.0.123" + + +async def test_dhcp_adds_mac_if_not_present( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that DHCP flow adds MAC address if not already in config entry data.""" + # Create a config entry without MAC address + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="TEST123456789", + data={CONF_HOST: "192.168.1.100"}, + ) + entry.add_to_hass(hass) + + # Verify initial state has no MAC + assert CONF_MAC not in entry.data + + # Simulate DHCP discovery event + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="a4e57caabbcc" + ), + ) + + # Verify flow aborts as device is already configured + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Verify MAC was added to the config entry + assert entry.data[CONF_HOST] == "192.168.0.123" + assert entry.data[CONF_MAC] == "a4e57caabbcc" + + +async def test_dhcp_preserves_existing_mac( + hass: HomeAssistant, mock_pooldose_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test that DHCP flow preserves existing MAC in config entry data.""" + # Create a config entry with MAC address already set + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="TEST123456789", + data={ + CONF_HOST: "192.168.1.100", + CONF_MAC: "existing11aabb", # Existing MAC that should be preserved + }, + ) + entry.add_to_hass(hass) + + # Verify initial state has the expected MAC + assert entry.data[CONF_MAC] == "existing11aabb" + + # Simulate DHCP discovery event with different MAC + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", hostname="kommspot", macaddress="different22ccdd" + ), + ) + + # Verify flow aborts as device is already configured + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Verify MAC in config entry was NOT updated (original MAC preserved) + assert entry.data[CONF_HOST] == "192.168.0.123" # IP was updated + assert entry.data[CONF_MAC] == "existing11aabb" # MAC remains unchanged + assert entry.data[CONF_MAC] != "different22ccdd" # Not updated to new MAC diff --git a/tests/components/pooldose/test_init.py b/tests/components/pooldose/test_init.py new file mode 100644 index 00000000000..572722c59c7 --- /dev/null +++ b/tests/components/pooldose/test_init.py @@ -0,0 +1,119 @@ +"""Test the PoolDose integration initialization.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.pooldose.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import RequestStatus + +from tests.common import MockConfigEntry + + +async def test_devices( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test all entities.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device({(DOMAIN, "TEST123456789")}) + + assert device is not None + assert device == snapshot + + +async def test_setup_entry_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup of 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 + + 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 + + +async def test_setup_entry_coordinator_refresh_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pooldose_client: AsyncMock, +) -> None: + """Test setup failure when coordinator first refresh fails.""" + mock_config_entry.add_to_hass(hass) + mock_pooldose_client.instant_values_structured.side_effect = Exception( + "API communication failed" + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "status", + [ + RequestStatus.HOST_UNREACHABLE, + RequestStatus.PARAMS_FETCH_FAILED, + RequestStatus.API_VERSION_UNSUPPORTED, + RequestStatus.NO_DATA, + RequestStatus.UNKNOWN_ERROR, + ], +) +async def test_setup_entry_various_client_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pooldose_client: AsyncMock, + status: RequestStatus, +) -> None: + """Test setup fails with various client error statuses.""" + mock_pooldose_client.connect.return_value = RequestStatus.HOST_UNREACHABLE + mock_pooldose_client.is_connected = False + 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.SETUP_RETRY + + +@pytest.mark.parametrize( + "exception", + [ + TimeoutError("Connection timeout"), + OSError("Network error"), + ], +) +async def test_setup_entry_timeout_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pooldose_client: AsyncMock, + exception: Exception, +) -> None: + """Test setup failure when client connection times out.""" + mock_pooldose_client.connect.side_effect = exception + mock_pooldose_client.is_connected = False + 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.SETUP_RETRY diff --git a/tests/components/pooldose/test_sensor.py b/tests/components/pooldose/test_sensor.py new file mode 100644 index 00000000000..1c7c2ce1555 --- /dev/null +++ b/tests/components/pooldose/test_sensor.py @@ -0,0 +1,256 @@ +"""Test the PoolDose sensor platform.""" + +from datetime import timedelta +import json +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pooldose.request_status import RequestStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.pooldose.const import DOMAIN +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_fixture, + snapshot_platform, +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_sensors( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Pooldose sensors.""" + with patch("homeassistant.components.pooldose.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("exception", [TimeoutError, ConnectionError, OSError]) +async def test_exception_raising( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Pooldose sensors.""" + 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 hass.states.get("sensor.pool_device_ph").state == "6.8" + + mock_pooldose_client.instant_values_structured.side_effect = exception + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.pool_device_ph").state == STATE_UNAVAILABLE + + +async def test_no_data( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Pooldose sensors.""" + 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 hass.states.get("sensor.pool_device_ph").state == "6.8" + + mock_pooldose_client.instant_values_structured.return_value = ( + RequestStatus.SUCCESS, + None, + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.pool_device_ph").state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("mock_pooldose_client") +async def test_ph_sensor_dynamic_unit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pooldose_client, +) -> None: + """Test pH sensor unit behavior - pH should not have unit_of_measurement.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Mock pH data with custom unit (should be ignored for pH sensor) + instant_values_raw = await async_load_fixture(hass, "instantvalues.json", DOMAIN) + updated_data = json.loads(instant_values_raw) + updated_data["sensor"]["ph"]["unit"] = "pH units" + + mock_pooldose_client.instant_values_structured.return_value = ( + RequestStatus.SUCCESS, + updated_data, + ) + + # Trigger refresh by reloading the integration (blackbox approach) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # pH sensor should not have unit_of_measurement (device class pH) + ph_state = hass.states.get("sensor.pool_device_ph") + assert "unit_of_measurement" not in ph_state.attributes + + +async def test_sensor_entity_unavailable_no_coordinator_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor entity becomes unavailable when coordinator has no data.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Verify initial working state + temp_state = hass.states.get("sensor.pool_device_temperature") + assert temp_state.state == "25" + + # Set coordinator data to None by making API return empty + mock_pooldose_client.instant_values_structured.return_value = ( + RequestStatus.HOST_UNREACHABLE, + None, + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check sensor becomes unavailable + temp_state = hass.states.get("sensor.pool_device_temperature") + assert temp_state.state == STATE_UNAVAILABLE + + +async def test_sensor_entity_unavailable_missing_platform_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor entity becomes unavailable when platform data is missing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Verify initial working state + temp_state = hass.states.get("sensor.pool_device_temperature") + assert temp_state.state == "25" + + # Remove sensor platform data by making API return data without sensors + mock_pooldose_client.instant_values_structured.return_value = ( + RequestStatus.SUCCESS, + {"other_platform": {}}, # No sensor data + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check sensor becomes unavailable + temp_state = hass.states.get("sensor.pool_device_temperature") + assert temp_state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("mock_pooldose_client") +async def test_temperature_sensor_dynamic_unit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test temperature sensor uses dynamic unit from API data.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Verify initial Celsius unit + temp_state = hass.states.get("sensor.pool_device_temperature") + assert temp_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + + # Change to Fahrenheit via mock update + instant_values_raw = await async_load_fixture(hass, "instantvalues.json", DOMAIN) + updated_data = json.loads(instant_values_raw) + updated_data["sensor"]["temperature"]["unit"] = "°F" + updated_data["sensor"]["temperature"]["value"] = 77 + + mock_pooldose_client.instant_values_structured.return_value = ( + RequestStatus.SUCCESS, + updated_data, + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check unit changed to Fahrenheit + temp_state = hass.states.get("sensor.pool_device_temperature") + # After reload, the original fixture data is restored, so we expect °C + assert temp_state.attributes["unit_of_measurement"] == UnitOfTemperature.CELSIUS + assert temp_state.state == "25.0" # Original fixture value + + +async def test_native_value_with_non_dict_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test native_value returns None when data is not a dict.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Mock get_data to return non-dict value + instant_values_raw = await async_load_fixture(hass, "instantvalues.json", DOMAIN) + malformed_data = json.loads(instant_values_raw) + malformed_data["sensor"]["temperature"] = "not_a_dict" + + mock_pooldose_client.instant_values_structured.return_value = ( + RequestStatus.SUCCESS, + malformed_data, + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should handle non-dict data gracefully + temp_state = hass.states.get("sensor.pool_device_temperature") + assert temp_state.state == STATE_UNKNOWN diff --git a/tests/components/portainer/__init__.py b/tests/components/portainer/__init__.py new file mode 100644 index 00000000000..ec381f42107 --- /dev/null +++ b/tests/components/portainer/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Portainer 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/portainer/conftest.py b/tests/components/portainer/conftest.py new file mode 100644 index 00000000000..446572083fa --- /dev/null +++ b/tests/components/portainer/conftest.py @@ -0,0 +1,66 @@ +"""Common fixtures for the portainer tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyportainer.models.docker import DockerContainer +from pyportainer.models.portainer import Endpoint +import pytest + +from homeassistant.components.portainer.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry, load_json_array_fixture + +MOCK_TEST_CONFIG = { + CONF_URL: "https://127.0.0.1:9000/", + CONF_API_TOKEN: "test_api_token", + CONF_VERIFY_SSL: True, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.portainer.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_portainer_client() -> Generator[AsyncMock]: + """Mock Portainer client with dynamic exception injection support.""" + with ( + patch( + "homeassistant.components.portainer.Portainer", autospec=True + ) as mock_client, + patch( + "homeassistant.components.portainer.config_flow.Portainer", new=mock_client + ), + ): + client = mock_client.return_value + + client.get_endpoints.return_value = [ + Endpoint.from_dict(endpoint) + for endpoint in load_json_array_fixture("endpoints.json", DOMAIN) + ] + client.get_containers.return_value = [ + DockerContainer.from_dict(container) + for container in load_json_array_fixture("containers.json", DOMAIN) + ] + client.restart_container = AsyncMock(return_value=None) + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Portainer test", + data=MOCK_TEST_CONFIG, + entry_id="portainer_test_entry_123", + version=2, + ) diff --git a/tests/components/portainer/fixtures/containers.json b/tests/components/portainer/fixtures/containers.json new file mode 100644 index 00000000000..a70da630549 --- /dev/null +++ b/tests/components/portainer/fixtures/containers.json @@ -0,0 +1,166 @@ +[ + { + "Id": "aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/funny_chatelet"], + "Image": "docker.io/library/ubuntu:latest", + "ImageID": "sha256:72297848456d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "ImageManifestDescriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96", + "size": 424, + "urls": ["http://example.com"], + "annotations": { + "com.docker.official-images.bashbrew.arch": "amd64", + "org.opencontainers.image.base.digest": "sha256:0d0ef5c914d3ea700147da1bd050c59edb8bb12ca312f3800b29d7c8087eabd8", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2025-01-27T00:00:00Z", + "org.opencontainers.image.revision": "9fabb4bad5138435b01857e2fe9363e2dc5f6a79", + "org.opencontainers.image.source": "https://git.launchpad.net/cloud-images/+oci/ubuntu-base", + "org.opencontainers.image.url": "https://hub.docker.com/_/ubuntu", + "org.opencontainers.image.version": "24.04" + }, + "data": null, + "platform": { + "architecture": "arm", + "os": "windows", + "os.version": "10.0.19041.1165", + "os.features": ["win32k"], + "variant": "v7" + }, + "artifactType": null + }, + "Command": "/bin/bash", + "Created": "1739811096", + "Ports": [ + { + "PrivatePort": 8080, + "PublicPort": 80, + "Type": "tcp" + } + ], + "SizeRw": "122880", + "SizeRootFs": "1653948416", + "Labels": { + "com.example.vendor": "Acme", + "com.example.license": "GPL", + "com.example.version": "1.0" + }, + "State": "running", + "Status": "Up 4 days", + "HostConfig": { + "NetworkMode": "mynetwork", + "Annotations": { + "io.kubernetes.docker.type": "container", + "io.kubernetes.sandbox.id": "3befe639bed0fd6afdd65fd1fa84506756f59360ec4adc270b0fdac9be22b4d3" + } + }, + "NetworkSettings": { + "Networks": { + "property1": { + "IPAMConfig": { + "IPv4Address": "172.20.30.33", + "IPv6Address": "2001:db8:abcd::3033", + "LinkLocalIPs": ["169.254.34.68", "fe80::3468"] + }, + "Links": ["container_1", "container_2"], + "MacAddress": "02:42:ac:11:00:04", + "Aliases": ["server_x", "server_y"], + "DriverOpts": { + "com.example.some-label": "some-value", + "com.example.some-other-label": "some-other-value" + }, + "GwPriority": [10], + "NetworkID": "08754567f1f40222263eab4102e1c733ae697e8e354aa9cd6e18d7402835292a", + "EndpointID": "b88f5b905aabf2893f3cbc4ee42d1ea7980bbc0a92e2c8922b1e1795298afb0b", + "Gateway": "172.17.0.1", + "IPAddress": "172.17.0.4", + "IPPrefixLen": 16, + "IPv6Gateway": "2001:db8:2::100", + "GlobalIPv6Address": "2001:db8::5689", + "GlobalIPv6PrefixLen": 64, + "DNSNames": ["foobar", "server_x", "server_y", "my.ctr"] + } + } + }, + "Mounts": [ + { + "Type": "volume", + "Name": "myvolume", + "Source": "/var/lib/docker/volumes/myvolume/_data", + "Destination": "/usr/share/nginx/html/", + "Driver": "local", + "Mode": "z", + "RW": true, + "Propagation": "" + } + ] + }, + { + "Id": "bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/serene_banach"], + "Image": "docker.io/library/nginx:latest", + "ImageID": "sha256:3f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "Command": "nginx -g 'daemon off;'", + "Created": "1739812096", + "Ports": [ + { + "PrivatePort": 80, + "PublicPort": 8081, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 2 days" + }, + { + "Id": "cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/stoic_turing"], + "Image": "docker.io/library/postgres:15", + "ImageID": "sha256:4f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "Command": "postgres", + "Created": "1739813096", + "Ports": [ + { + "PrivatePort": 5432, + "PublicPort": 5432, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 1 day" + }, + { + "Id": "dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/focused_einstein"], + "Image": "docker.io/library/redis:7", + "ImageID": "sha256:5f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "Command": "redis-server", + "Created": "1739814096", + "Ports": [ + { + "PrivatePort": 6379, + "PublicPort": 6379, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 12 hours" + }, + { + "Id": "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/practical_morse"], + "Image": "docker.io/library/python:3.13-slim", + "ImageID": "sha256:6f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "Command": "python3 -m http.server", + "Created": "1739815096", + "Ports": [ + { + "PrivatePort": 8000, + "PublicPort": 8000, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 6 hours" + } +] diff --git a/tests/components/portainer/fixtures/endpoints.json b/tests/components/portainer/fixtures/endpoints.json new file mode 100644 index 00000000000..95e728a4ac3 --- /dev/null +++ b/tests/components/portainer/fixtures/endpoints.json @@ -0,0 +1,195 @@ +[ + { + "AMTDeviceGUID": "4c4c4544-004b-3910-8037-b6c04f504633", + "AuthorizedTeams": [1], + "AuthorizedUsers": [1], + "AzureCredentials": { + "ApplicationID": "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4", + "AuthenticationKey": "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=", + "TenantID": "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + }, + "ComposeSyntaxMaxVersion": "3.8", + "ContainerEngine": "docker", + "EdgeCheckinInterval": 5, + "EdgeID": "string", + "EdgeKey": "string", + "EnableGPUManagement": true, + "Gpus": [ + { + "name": "name", + "value": "value" + } + ], + "GroupId": 1, + "Heartbeat": true, + "Id": 1, + "IsEdgeDevice": true, + "Kubernetes": { + "Configuration": { + "AllowNoneIngressClass": true, + "EnableResourceOverCommit": true, + "IngressAvailabilityPerNamespace": true, + "IngressClasses": [ + { + "Blocked": true, + "BlockedNamespaces": ["string"], + "Name": "string", + "Type": "string" + } + ], + "ResourceOverCommitPercentage": 0, + "RestrictDefaultNamespace": true, + "StorageClasses": [ + { + "AccessModes": ["string"], + "AllowVolumeExpansion": true, + "Name": "string", + "Provisioner": "string" + } + ], + "UseLoadBalancer": true, + "UseServerMetrics": true + }, + "Flags": { + "IsServerIngressClassDetected": true, + "IsServerMetricsDetected": true, + "IsServerStorageDetected": true + }, + "Snapshots": [ + { + "DiagnosticsData": { + "DNS": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Log": "string", + "Proxy": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Telnet": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + } + }, + "KubernetesVersion": "string", + "NodeCount": 0, + "Time": 0, + "TotalCPU": 0, + "TotalMemory": 0 + } + ] + }, + "Name": "my-environment", + "PostInitMigrations": { + "MigrateGPUs": true, + "MigrateIngresses": true + }, + "PublicURL": "docker.mydomain.tld:2375", + "Snapshots": [ + { + "ContainerCount": 0, + "DiagnosticsData": { + "DNS": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Log": "string", + "Proxy": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Telnet": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + } + }, + "DockerSnapshotRaw": {}, + "DockerVersion": "string", + "GpuUseAll": true, + "GpuUseList": ["string"], + "HealthyContainerCount": 0, + "ImageCount": 0, + "IsPodman": true, + "NodeCount": 0, + "RunningContainerCount": 0, + "ServiceCount": 0, + "StackCount": 0, + "StoppedContainerCount": 0, + "Swarm": true, + "Time": 0, + "TotalCPU": 0, + "TotalMemory": 0, + "UnhealthyContainerCount": 0, + "VolumeCount": 0 + } + ], + "Status": 1, + "TLS": true, + "TLSCACert": "string", + "TLSCert": "string", + "TLSConfig": { + "TLS": true, + "TLSCACert": "/data/tls/ca.pem", + "TLSCert": "/data/tls/cert.pem", + "TLSKey": "/data/tls/key.pem", + "TLSSkipVerify": false + }, + "TLSKey": "string", + "TagIds": [1], + "Tags": ["string"], + "TeamAccessPolicies": { + "additionalProp1": { + "RoleId": 1 + }, + "additionalProp2": { + "RoleId": 1 + }, + "additionalProp3": { + "RoleId": 1 + } + }, + "Type": 1, + "URL": "docker.mydomain.tld:2375", + "UserAccessPolicies": { + "additionalProp1": { + "RoleId": 1 + }, + "additionalProp2": { + "RoleId": 1 + }, + "additionalProp3": { + "RoleId": 1 + } + }, + "UserTrusted": true, + "agent": { + "version": "1.0.0" + }, + "edge": { + "CommandInterval": 60, + "PingInterval": 60, + "SnapshotInterval": 60, + "asyncMode": true + }, + "lastCheckInDate": 0, + "queryDate": 0, + "securitySettings": { + "allowBindMountsForRegularUsers": false, + "allowContainerCapabilitiesForRegularUsers": true, + "allowDeviceMappingForRegularUsers": true, + "allowHostNamespaceForRegularUsers": true, + "allowPrivilegedModeForRegularUsers": false, + "allowStackManagementForRegularUsers": true, + "allowSysctlSettingForRegularUsers": true, + "allowVolumeBrowserForRegularUsers": true, + "enableHostManagementFeatures": true + } + } +] diff --git a/tests/components/portainer/snapshots/test_binary_sensor.ambr b/tests/components/portainer/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..7ec3900e49b --- /dev/null +++ b/tests/components/portainer/snapshots/test_binary_sensor.ambr @@ -0,0 +1,295 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.focused_einstein_status-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.focused_einstein_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': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_focused_einstein_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.focused_einstein_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'focused_einstein Status', + }), + 'context': , + 'entity_id': 'binary_sensor.focused_einstein_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.funny_chatelet_status-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.funny_chatelet_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': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.funny_chatelet_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'funny_chatelet Status', + }), + 'context': , + 'entity_id': 'binary_sensor.funny_chatelet_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.my_environment_status-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.my_environment_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': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.my_environment_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'my-environment Status', + }), + 'context': , + 'entity_id': 'binary_sensor.my_environment_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.practical_morse_status-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.practical_morse_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': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_practical_morse_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.practical_morse_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'practical_morse Status', + }), + 'context': , + 'entity_id': 'binary_sensor.practical_morse_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.serene_banach_status-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.serene_banach_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': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_serene_banach_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.serene_banach_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'serene_banach Status', + }), + 'context': , + 'entity_id': 'binary_sensor.serene_banach_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.stoic_turing_status-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.stoic_turing_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': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_stoic_turing_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.stoic_turing_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'stoic_turing Status', + }), + 'context': , + 'entity_id': 'binary_sensor.stoic_turing_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/portainer/snapshots/test_button.ambr b/tests/components/portainer/snapshots/test_button.ambr new file mode 100644 index 00000000000..83d4f65aaf2 --- /dev/null +++ b/tests/components/portainer/snapshots/test_button.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_all_button_entities_snapshot[button.focused_einstein_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.focused_einstein_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_focused_einstein_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.focused_einstein_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'focused_einstein Restart Container', + }), + 'context': , + 'entity_id': 'button.focused_einstein_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.funny_chatelet_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.funny_chatelet_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_funny_chatelet_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.funny_chatelet_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'funny_chatelet Restart Container', + }), + 'context': , + 'entity_id': 'button.funny_chatelet_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.practical_morse_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.practical_morse_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_practical_morse_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.practical_morse_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'practical_morse Restart Container', + }), + 'context': , + 'entity_id': 'button.practical_morse_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.serene_banach_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.serene_banach_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_serene_banach_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.serene_banach_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'serene_banach Restart Container', + }), + 'context': , + 'entity_id': 'button.serene_banach_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_button_entities_snapshot[button.stoic_turing_restart_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.stoic_turing_restart_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'portainer_test_entry_123_stoic_turing_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.stoic_turing_restart_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'stoic_turing Restart Container', + }), + 'context': , + 'entity_id': 'button.stoic_turing_restart_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/portainer/snapshots/test_switch.ambr b/tests/components/portainer/snapshots/test_switch.ambr new file mode 100644 index 00000000000..6e749d8212f --- /dev/null +++ b/tests/components/portainer/snapshots/test_switch.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_all_switch_entities_snapshot[switch.focused_einstein_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.focused_einstein_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_focused_einstein_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.focused_einstein_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'focused_einstein Container', + }), + 'context': , + 'entity_id': 'switch.focused_einstein_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switch_entities_snapshot[switch.funny_chatelet_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.funny_chatelet_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.funny_chatelet_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'funny_chatelet Container', + }), + 'context': , + 'entity_id': 'switch.funny_chatelet_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switch_entities_snapshot[switch.practical_morse_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.practical_morse_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_practical_morse_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.practical_morse_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'practical_morse Container', + }), + 'context': , + 'entity_id': 'switch.practical_morse_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switch_entities_snapshot[switch.serene_banach_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.serene_banach_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_serene_banach_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.serene_banach_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'serene_banach Container', + }), + 'context': , + 'entity_id': 'switch.serene_banach_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switch_entities_snapshot[switch.stoic_turing_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.stoic_turing_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_stoic_turing_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.stoic_turing_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'stoic_turing Container', + }), + 'context': , + 'entity_id': 'switch.stoic_turing_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/portainer/test_binary_sensor.py b/tests/components/portainer/test_binary_sensor.py new file mode 100644 index 00000000000..e31937b64f7 --- /dev/null +++ b/tests/components/portainer/test_binary_sensor.py @@ -0,0 +1,99 @@ +"""Tests for the Portainer binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.portainer.coordinator import DEFAULT_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("exception"), + [ + PortainerAuthenticationError("bad creds"), + PortainerConnectionError("cannot connect"), + PortainerTimeoutError("timeout"), + ], +) +async def test_refresh_endpoints_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test entities go unavailable after coordinator refresh failures, for the endpoint fetch.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_portainer_client.get_endpoints.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.practical_morse_status") + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("exception"), + [ + PortainerAuthenticationError("bad creds"), + PortainerConnectionError("cannot connect"), + PortainerTimeoutError("timeout"), + ], +) +async def test_refresh_containers_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test entities go unavailable after coordinator refresh failures, for the container fetch.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_portainer_client.get_containers.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.practical_morse_status") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/portainer/test_button.py b/tests/components/portainer/test_button.py new file mode 100644 index 00000000000..8f99e2faa20 --- /dev/null +++ b/tests/components/portainer/test_button.py @@ -0,0 +1,114 @@ +"""Tests for the Portainer button platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +BUTTON_DOMAIN = "button" + + +async def test_all_button_entities_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test for all Portainer button entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("action", "client_method"), + [ + ("restart", "restart_container"), + ], +) +async def test_buttons( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + client_method: str, +) -> None: + """Test pressing a Portainer container action button triggers client call. Click, click!""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + + entity_id = f"button.practical_morse_{action}_container" + method_mock = getattr(mock_portainer_client, client_method) + pre_calls = len(method_mock.mock_calls) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(method_mock.mock_calls) == pre_calls + 1 + + +@pytest.mark.parametrize( + ("exception", "client_method"), + [ + (PortainerAuthenticationError("auth"), "restart_container"), + (PortainerConnectionError("conn"), "restart_container"), + (PortainerTimeoutError("timeout"), "restart_container"), + ], +) +async def test_buttons_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + client_method: str, +) -> None: + """Test that Portainer buttons, but this time when they will do boom for sure.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + + action = client_method.split("_")[0] + entity_id = f"button.practical_morse_{action}_container" + + method_mock = getattr(mock_portainer_client, client_method) + method_mock.side_effect = exception + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/portainer/test_config_flow.py b/tests/components/portainer/test_config_flow.py new file mode 100644 index 00000000000..9bc645f4f34 --- /dev/null +++ b/tests/components/portainer/test_config_flow.py @@ -0,0 +1,224 @@ +"""Test the Portainer config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest + +from homeassistant.components.portainer.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_TEST_CONFIG + +from tests.common import MockConfigEntry + +MOCK_USER_SETUP = { + CONF_URL: "https://127.0.0.1:9000/", + CONF_API_TOKEN: "test_api_token", + CONF_VERIFY_SSL: True, +} + + +async def test_form( + hass: HomeAssistant, + mock_portainer_client: MagicMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:9000/" + assert result["data"] == MOCK_TEST_CONFIG + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + PortainerAuthenticationError, + "invalid_auth", + ), + ( + PortainerConnectionError, + "cannot_connect", + ), + ( + PortainerTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions.""" + mock_portainer_client.get_endpoints.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"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=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_portainer_client.get_endpoints.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:9000/" + assert result["data"] == MOCK_TEST_CONFIG + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle duplicate entries.""" + 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["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_flow_reauth( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full flow of the config flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # There is no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + 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_TOKEN: "new_api_key"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_TOKEN] == "new_api_key" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + PortainerAuthenticationError, + "invalid_auth", + ), + ( + PortainerConnectionError, + "cannot_connect", + ), + ( + PortainerTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions in the reauth flow.""" + mock_config_entry.add_to_hass(hass) + + mock_portainer_client.get_endpoints.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "new_api_key"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + # Now test that we can recover from the error + mock_portainer_client.get_endpoints.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "new_api_key"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_TOKEN] == "new_api_key" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py new file mode 100644 index 00000000000..4e661e22505 --- /dev/null +++ b/tests/components/portainer/test_init.py @@ -0,0 +1,71 @@ +"""Test the Portainer initial specific behavior.""" + +from unittest.mock import AsyncMock + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest + +from homeassistant.components.portainer.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_API_TOKEN, + CONF_HOST, + CONF_URL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (PortainerAuthenticationError("bad creds"), ConfigEntryState.SETUP_ERROR), + (PortainerConnectionError("cannot connect"), ConfigEntryState.SETUP_RETRY), + (PortainerTimeoutError("timeout"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test the _async_setup.""" + mock_portainer_client.get_endpoints.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state == expected_state + + +async def test_migrations(hass: HomeAssistant) -> None: + """Test migration from v1 config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "http://test_host", + CONF_API_KEY: "test_key", + }, + unique_id="1", + version=1, + ) + entry.add_to_hass(hass) + assert entry.version == 1 + assert CONF_VERIFY_SSL not in entry.data + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 3 + assert CONF_HOST not in entry.data + assert CONF_API_KEY not in entry.data + assert entry.data[CONF_URL] == "http://test_host" + assert entry.data[CONF_API_TOKEN] == "test_key" + assert entry.data[CONF_VERIFY_SSL] is True diff --git a/tests/components/portainer/test_switch.py b/tests/components/portainer/test_switch.py new file mode 100644 index 00000000000..c738c1a264f --- /dev/null +++ b/tests/components/portainer/test_switch.py @@ -0,0 +1,126 @@ +"""Tests for the Portainer switch platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_portainer_client") +async def test_all_switch_entities_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test for all Portainer switch entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("service_call", "client_method"), + [ + (SERVICE_TURN_ON, "start_container"), + (SERVICE_TURN_OFF, "stop_container"), + ], +) +async def test_turn_off_on( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service_call: str, + client_method: str, +) -> None: + """Test the switches. Have you tried to turn it off and on again?""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.practical_morse_container" + method_mock = getattr(mock_portainer_client, client_method) + + await hass.services.async_call( + SWITCH_DOMAIN, + service_call, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Matches the endpoint ID and container ID + method_mock.assert_called_once_with( + 1, "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf" + ) + + +@pytest.mark.parametrize( + ("service_call", "client_method"), + [ + (SERVICE_TURN_ON, "start_container"), + (SERVICE_TURN_OFF, "stop_container"), + ], +) +@pytest.mark.parametrize( + ("raise_exception", "expected_exception"), + [ + (PortainerAuthenticationError, HomeAssistantError), + (PortainerConnectionError, HomeAssistantError), + (PortainerTimeoutError, HomeAssistantError), + ], +) +async def test_turn_off_on_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service_call: str, + client_method: str, + raise_exception: Exception, + expected_exception: Exception, +) -> None: + """Test the switches. Have you tried to turn it off and on again? This time they will do boom!""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.practical_morse_container" + method_mock = getattr(mock_portainer_client, client_method) + + method_mock.side_effect = raise_exception + with pytest.raises(expected_exception): + await hass.services.async_call( + SWITCH_DOMAIN, + service_call, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index e724a9e5cab..941d639a419 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -5,6 +5,7 @@ from functools import lru_cache import logging import os from pathlib import Path +import socket from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -18,6 +19,7 @@ from homeassistant.components.profiler import ( CONF_ENABLED, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, + SERVICE_DUMP_SOCKETS, SERVICE_LOG_CURRENT_TASKS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, SERVICE_LOG_THREAD_FRAMES, @@ -271,6 +273,36 @@ async def test_log_scheduled( await hass.async_block_till_done() +@pytest.mark.usefixtures("socket_enabled") +async def test_dump_sockets( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test dumping of sockets to the log.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + caplog.clear() + + sock = None + try: + # Try to bind ephemeral UDP port on localhost for testing + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + assert hass.services.has_service(DOMAIN, SERVICE_DUMP_SOCKETS) + await hass.services.async_call(DOMAIN, SERVICE_DUMP_SOCKETS, blocking=True) + finally: + if sock: + sock.close() + + assert "Sockets used by Home Assistant" in caplog.text + assert f"laddr=('127.0.0.1', {port})" in caplog.text + + async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test logging lru stats.""" diff --git a/tests/components/prowl/__init__.py b/tests/components/prowl/__init__.py new file mode 100644 index 00000000000..55e7a9fea00 --- /dev/null +++ b/tests/components/prowl/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prowl Notification Component.""" diff --git a/tests/components/prowl/conftest.py b/tests/components/prowl/conftest.py new file mode 100644 index 00000000000..874d6e36a3b --- /dev/null +++ b/tests/components/prowl/conftest.py @@ -0,0 +1,43 @@ +"""Test fixtures for Prowl.""" + +from collections.abc import Generator +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.prowl.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +TEST_API_KEY = "f00f" * 10 + + +@pytest.fixture +async def configure_prowl_through_yaml( + hass: HomeAssistant, mock_prowlpy: Generator[Mock] +) -> Generator[None]: + """Configure the notify domain with YAML for the Prowl platform.""" + await async_setup_component( + hass, + NOTIFY_DOMAIN, + { + NOTIFY_DOMAIN: [ + { + "name": DOMAIN, + "platform": DOMAIN, + "api_key": TEST_API_KEY, + }, + ] + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +def mock_prowlpy() -> Generator[Mock]: + """Mock the prowlpy library.""" + + with patch("homeassistant.components.prowl.notify.prowlpy.Prowl") as MockProwl: + mock_instance = MockProwl.return_value + yield mock_instance diff --git a/tests/components/prowl/test_notify.py b/tests/components/prowl/test_notify.py new file mode 100644 index 00000000000..8047ed177e6 --- /dev/null +++ b/tests/components/prowl/test_notify.py @@ -0,0 +1,133 @@ +"""Test the Prowl notifications.""" + +from typing import Any +from unittest.mock import Mock + +import prowlpy +import pytest + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.prowl.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import TEST_API_KEY + +SERVICE_DATA = {"message": "Test Notification", "title": "Test Title"} + +EXPECTED_SEND_PARAMETERS = { + "application": "Home-Assistant", + "event": "Test Title", + "description": "Test Notification", + "priority": 0, + "url": None, +} + + +@pytest.mark.usefixtures("configure_prowl_through_yaml") +async def test_send_notification_service( + hass: HomeAssistant, + mock_prowlpy: Mock, +) -> None: + """Set up Prowl, call notify service, and check API call.""" + assert hass.services.has_service(NOTIFY_DOMAIN, DOMAIN) + await hass.services.async_call( + NOTIFY_DOMAIN, + DOMAIN, + SERVICE_DATA, + blocking=True, + ) + + mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS) + + +@pytest.mark.parametrize( + ("prowlpy_side_effect", "raised_exception", "exception_message"), + [ + ( + prowlpy.APIError("Internal server error"), + HomeAssistantError, + "Unexpected error when calling Prowl API", + ), + ( + TimeoutError, + HomeAssistantError, + "Timeout accessing Prowl API", + ), + ( + prowlpy.APIError(f"Invalid API key: {TEST_API_KEY}"), + HomeAssistantError, + "Invalid API key for Prowl service", + ), + ( + prowlpy.APIError( + "Not accepted: Your IP address has exceeded the API limit" + ), + HomeAssistantError, + "Prowl service reported: exceeded rate limit", + ), + ( + SyntaxError(), + SyntaxError, + None, + ), + ], +) +@pytest.mark.usefixtures("configure_prowl_through_yaml") +async def test_fail_send_notification( + hass: HomeAssistant, + mock_prowlpy: Mock, + prowlpy_side_effect: Exception, + raised_exception: type[Exception], + exception_message: str | None, +) -> None: + """Sending a message via Prowl with a failure.""" + mock_prowlpy.send.side_effect = prowlpy_side_effect + + assert hass.services.has_service(NOTIFY_DOMAIN, DOMAIN) + with pytest.raises(raised_exception, match=exception_message): + await hass.services.async_call( + NOTIFY_DOMAIN, + DOMAIN, + SERVICE_DATA, + blocking=True, + ) + + mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS) + + +@pytest.mark.parametrize( + ("service_data", "expected_send_parameters"), + [ + ( + {"message": "Test Notification", "title": "Test Title"}, + { + "application": "Home-Assistant", + "event": "Test Title", + "description": "Test Notification", + "priority": 0, + "url": None, + }, + ) + ], +) +@pytest.mark.usefixtures("configure_prowl_through_yaml") +async def test_other_exception_send_notification( + hass: HomeAssistant, + mock_prowlpy: Mock, + service_data: dict[str, Any], + expected_send_parameters: dict[str, Any], +) -> None: + """Sending a message via Prowl with a general unhandled exception.""" + mock_prowlpy.send.side_effect = SyntaxError + + assert hass.services.has_service(NOTIFY_DOMAIN, DOMAIN) + with pytest.raises(SyntaxError): + await hass.services.async_call( + NOTIFY_DOMAIN, + DOMAIN, + SERVICE_DATA, + blocking=True, + ) + + mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS) diff --git a/tests/components/pushover/test_notify.py b/tests/components/pushover/test_notify.py new file mode 100644 index 00000000000..52f58185583 --- /dev/null +++ b/tests/components/pushover/test_notify.py @@ -0,0 +1,71 @@ +"""Test the pushover notify platform.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.pushover import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=False) +def mock_pushover(): + """Mock pushover.""" + with patch( + "pushover_complete.PushoverAPI._generic_post", return_value={} + ) as mock_generic_post: + yield mock_generic_post + + +@pytest.fixture +def mock_send_message(): + """Patch PushoverAPI.send_message for TTL test.""" + with patch( + "homeassistant.components.pushover.notify.PushoverAPI.send_message" + ) as mock: + yield mock + + +async def test_send_message( + hass: HomeAssistant, mock_pushover: MagicMock, mock_send_message: MagicMock +) -> None: + """Test sending a message.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "name": "pushover", + "api_key": "API_KEY", + "user_key": "USER_KEY", + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "notify", + "pushover", + {"message": "Hello TTL", "data": {"ttl": 900}}, + blocking=True, + ) + + mock_send_message.assert_called_once_with( + user="USER_KEY", + message="Hello TTL", + device="", + title="Home Assistant", + url=None, + url_title=None, + image=None, + priority=None, + retry=None, + expire=None, + callback_url=None, + timestamp=None, + sound=None, + html=0, + ttl=900, + ) diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index fbaeb8aa5a3..c76bd6ace03 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -121,16 +121,14 @@ async def test_config_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "api_token" assert pvpc_aioclient_mock.call_count == 2 - result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert pvpc_aioclient_mock.call_count == 2 await hass.async_block_till_done() state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 4 + assert pvpc_aioclient_mock.call_count == 3 assert state.attributes["period"] == "P3" assert state.attributes["next_period"] == "P2" assert state.attributes["available_power"] == 4600 @@ -151,7 +149,7 @@ async def test_config_flow( state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[0], value="unavailable") assert "period" not in state.attributes - assert pvpc_aioclient_mock.call_count == 6 + assert pvpc_aioclient_mock.call_count == 5 # disable api token in options result = await hass.config_entries.options.async_init(config_entry.entry_id) @@ -163,9 +161,8 @@ async def test_config_flow( user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: False}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert pvpc_aioclient_mock.call_count == 6 await hass.async_block_till_done() - assert pvpc_aioclient_mock.call_count == 7 + assert pvpc_aioclient_mock.call_count == 6 state = hass.states.get("sensor.esios_pvpc") state_inyection = hass.states.get("sensor.esios_injection_price") diff --git a/tests/components/qbittorrent/conftest.py b/tests/components/qbittorrent/conftest.py index 17fb8e15b47..4bc8a7b899c 100644 --- a/tests/components/qbittorrent/conftest.py +++ b/tests/components/qbittorrent/conftest.py @@ -6,6 +6,11 @@ from unittest.mock import AsyncMock, patch import pytest import requests_mock +from homeassistant.components.qbittorrent import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry, load_json_object_fixture + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -24,3 +29,31 @@ def mock_api() -> Generator[requests_mock.Mocker]: mocker.get("http://localhost:8080/api/v2/transfer/speedLimitsMode") mocker.post("http://localhost:8080/api/v2/auth/login", text="Ok.") yield mocker + + +@pytest.fixture +def mock_qbittorrent() -> Generator[AsyncMock]: + """Mock qbittorrent client.""" + with patch( + "homeassistant.components.qbittorrent.helpers.Client", autospec=True + ) as mock_client: + client = mock_client.return_value + client.sync_maindata.return_value = load_json_object_fixture( + "sync_maindata.json", DOMAIN + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry for qbittorrent.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "admin", + CONF_PASSWORD: "adminadmin", + CONF_VERIFY_SSL: False, + }, + entry_id="01K6E7464PTQKDE24VAJQZPTH2", + ) diff --git a/tests/components/qbittorrent/fixtures/sync_maindata.json b/tests/components/qbittorrent/fixtures/sync_maindata.json new file mode 100644 index 00000000000..6a7e74c0bf0 --- /dev/null +++ b/tests/components/qbittorrent/fixtures/sync_maindata.json @@ -0,0 +1,392 @@ +{ + "categories": { + "radarr": { + "name": "radarr", + "savePath": "" + } + }, + "full_update": true, + "rid": 2, + "server_state": { + "alltime_dl": 861098349149, + "alltime_ul": 724759510499, + "average_time_queue": 27, + "connection_status": "connected", + "dht_nodes": 370, + "dl_info_data": 127006787927, + "dl_info_speed": 0, + "dl_rate_limit": 0, + "free_space_on_disk": 87547486208, + "global_ratio": "0.84", + "last_external_address_v4": "1.1.1.1", + "last_external_address_v6": "", + "queued_io_jobs": 0, + "queueing": true, + "read_cache_hits": "0", + "read_cache_overload": "0", + "refresh_interval": 1500, + "total_buffers_size": 0, + "total_peer_connections": 2, + "total_queued_size": 0, + "total_wasted_session": 374937191, + "up_info_data": 119803126285, + "up_info_speed": 0, + "up_rate_limit": 0, + "use_alt_speed_limits": false, + "use_subcategories": false, + "write_cache_overload": "0" + }, + "torrents": { + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fe": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "stalledDL", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fb": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "stoppedDL", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fc": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "stalledUP", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fd": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "error", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915ff": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "missingFiles", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fa": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "downloading", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + } + }, + "trackers": { + "http://tracker.ipv6tracker.org:80/announce": ["abc"] + } +} diff --git a/tests/components/qbittorrent/snapshots/test_sensor.ambr b/tests/components/qbittorrent/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2f1cfe985ed --- /dev/null +++ b/tests/components/qbittorrent/snapshots/test_sensor.ambr @@ -0,0 +1,773 @@ +# serializer version: 1 +# name: test_entities[sensor.mock_title_active_torrents-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': None, + 'entity_id': 'sensor.mock_title_active_torrents', + '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': 'Active torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-active_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_active_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Active torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_active_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_entities[sensor.mock_title_all_time_download-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_all_time_download', + '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': 'All-time download', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alltime_download', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-alltime_download', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_all_time_download-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title All-time download', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_all_time_download', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.783164386256431', + }) +# --- +# name: test_entities[sensor.mock_title_all_time_upload-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_all_time_upload', + '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': 'TiB', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'All-time upload', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alltime_upload', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-alltime_upload', + 'unit_of_measurement': 'TiB', + }) +# --- +# name: test_entities[sensor.mock_title_all_time_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title All-time upload', + 'state_class': , + 'unit_of_measurement': 'TiB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_all_time_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.659164934858381', + }) +# --- +# name: test_entities[sensor.mock_title_all_torrents-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': None, + 'entity_id': 'sensor.mock_title_all_torrents', + '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': 'All torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'all_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-all_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_all_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title All torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_all_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_entities[sensor.mock_title_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'firewalled', + 'disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_connection_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': 'Connection status', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_status', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_title_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Connection status', + 'options': list([ + 'connected', + 'firewalled', + 'disconnected', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_entities[sensor.mock_title_download_speed-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_download_speed', + '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': 'Download speed', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'download_speed', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-download_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_download_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor.mock_title_download_speed_limit-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_download_speed_limit', + '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': 'Download speed limit', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'download_speed_limit', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-download_speed_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_download_speed_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download speed limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_speed_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor.mock_title_errored_torrents-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': None, + 'entity_id': 'sensor.mock_title_errored_torrents', + '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': 'Errored torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'errored_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-errored_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_errored_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Errored torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_errored_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_entities[sensor.mock_title_global_ratio-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_global_ratio', + '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': 'Global ratio', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'global_ratio', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-global_ratio', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_title_global_ratio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Global ratio', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_global_ratio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.84', + }) +# --- +# name: test_entities[sensor.mock_title_inactive_torrents-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': None, + 'entity_id': 'sensor.mock_title_inactive_torrents', + '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': 'Inactive torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inactive_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-inactive_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_inactive_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Inactive torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_inactive_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_entities[sensor.mock_title_paused_torrents-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': None, + 'entity_id': 'sensor.mock_title_paused_torrents', + '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': 'Paused torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'paused_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-paused_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_paused_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Paused torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_paused_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_entities[sensor.mock_title_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'up_down', + 'seeding', + 'downloading', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_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': 'Status', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_status', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-current_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_title_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Status', + 'options': list([ + 'idle', + 'up_down', + 'seeding', + 'downloading', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_entities[sensor.mock_title_upload_speed-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_upload_speed', + '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': 'Upload speed', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'upload_speed', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-upload_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_upload_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor.mock_title_upload_speed_limit-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_upload_speed_limit', + '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': 'Upload speed limit', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'upload_speed_limit', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-upload_speed_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_upload_speed_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload speed limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_speed_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/qbittorrent/test_sensor.py b/tests/components/qbittorrent/test_sensor.py new file mode 100644 index 00000000000..e07df7988a8 --- /dev/null +++ b/tests/components/qbittorrent/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the qBittorrent sensor platform, including errored torrents.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + mock_qbittorrent: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that sensors are created.""" + with patch("homeassistant.components.qbittorrent.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/radio_browser/conftest.py b/tests/components/radio_browser/conftest.py index fc666b32c53..24bd93e48a7 100644 --- a/tests/components/radio_browser/conftest.py +++ b/tests/components/radio_browser/conftest.py @@ -3,11 +3,12 @@ from __future__ import annotations from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.radio_browser.const import DOMAIN +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -29,3 +30,108 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.radio_browser.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the Radio Browser 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() + + return mock_config_entry + + +@pytest.fixture +def mock_countries(): + "Generate mock countries for the countries method of the radios object." + + class MockCountry: + """Country Object for Radios.""" + + def __init__(self, code, name) -> None: + """Initialize a mock country.""" + self.code = code + self.name = name + self.favicon = "fake.png" + + return [MockCountry("US", "United States")] + + +@pytest.fixture +def mock_stations(): + "Generate mock stations for the stations method of the radios object." + + class MockStation: + """Station object for Radios.""" + + def __init__(self, country_code, latitude, longitude, name, uuid) -> None: + """Initialize a mock station.""" + self.country_code = country_code + self.latitude = latitude + self.longitude = longitude + self.uuid = uuid + self.name = name + self.codec = "MP3" + self.favicon = "fake.png" + + return [ + MockStation( + country_code="US", + latitude=45.52000, + longitude=-122.63961, + name="Near Station 1", + uuid="1", + ), + MockStation( + country_code="US", + latitude=None, + longitude=None, + name="Unknown location station", + uuid="2", + ), + MockStation( + country_code="US", + latitude=47.57071, + longitude=-122.21148, + name="Moderate Far Station", + uuid="3", + ), + MockStation( + country_code="US", + latitude=45.73943, + longitude=-121.51859, + name="Near Station 2", + uuid="4", + ), + MockStation( + country_code="US", + latitude=44.99026, + longitude=-69.27804, + name="Really Far Station", + uuid="5", + ), + ] + + +@pytest.fixture +def mock_radios(mock_countries, mock_stations): + """Provide a radios mock object.""" + radios = MagicMock() + radios.countries = AsyncMock(return_value=mock_countries) + radios.stations = AsyncMock(return_value=mock_stations) + return radios + + +@pytest.fixture +def patch_radios(monkeypatch: pytest.MonkeyPatch, mock_radios): + """Replace the radios object in the source with the mock object (with mock stations and countries).""" + + def _patch(source): + monkeypatch.setattr(type(source), "radios", mock_radios) + + return _patch diff --git a/tests/components/radio_browser/test_media_source.py b/tests/components/radio_browser/test_media_source.py new file mode 100644 index 00000000000..a9d08c1e438 --- /dev/null +++ b/tests/components/radio_browser/test_media_source.py @@ -0,0 +1,73 @@ +"""Tests for radio_browser media_source.""" + +from unittest.mock import AsyncMock + +import pytest +from radios import FilterBy, Order + +from homeassistant.components import media_source +from homeassistant.components.radio_browser.media_source import async_get_media_source +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +DOMAIN = "radio_browser" + + +@pytest.fixture(autouse=True) +async def setup_media_source(hass: HomeAssistant) -> None: + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + + +async def test_browsing_local( + hass: HomeAssistant, init_integration: AsyncMock, patch_radios +) -> None: + """Test browsing local stations.""" + + hass.config.latitude = 45.58539 + hass.config.longitude = -122.40320 + hass.config.country = "US" + + source = await async_get_media_source(hass) + patch_radios(source) + + item = await media_source.async_browse_media( + hass, f"{media_source.URI_SCHEME}{DOMAIN}" + ) + + assert item is not None + assert item.title == "My Radios" + assert item.children is not None + assert len(item.children) == 5 + assert item.can_play is False + assert item.can_expand is True + + assert item.children[3].title == "Local stations" + + item_child = await media_source.async_browse_media( + hass, item.children[3].media_content_id + ) + + source.radios.stations.assert_awaited_with( + filter_by=FilterBy.COUNTRY_CODE_EXACT, + filter_term=hass.config.country, + hide_broken=True, + order=Order.NAME, + reverse=False, + ) + + assert item_child is not None + assert item_child.title == "My Radios" + assert len(item_child.children) == 2 + assert item_child.children[0].title == "Near Station 1" + assert item_child.children[1].title == "Near Station 2" + + # Test browsing a different category to hit the path where async_build_local + # returns [] + other_browse = await media_source.async_browse_media( + hass, f"{media_source.URI_SCHEME}{DOMAIN}/nonexistent" + ) + + assert other_browse is not None + assert other_browse.title == "My Radios" + assert len(other_browse.children) == 0 diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 2466a761364..91f51b4e0c9 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -19,7 +19,7 @@ from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from ...common import async_wait_recording_done +from ...common import async_wait_recording_done, get_patched_live_version from tests.common import async_test_home_assistant from tests.typing import RecorderInstanceContextManager @@ -189,6 +189,11 @@ async def test_delete_metadata_duplicates( patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), + patch.object( + recorder.migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object( recorder.migration, "non_live_data_migration_needed", return_value=False ), @@ -309,6 +314,11 @@ async def test_delete_metadata_duplicates_many( patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), + patch.object( + recorder.migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object( recorder.migration, "non_live_data_migration_needed", return_value=False ), diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index bf2a925df17..55b03419767 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -30,9 +30,9 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("enable_schema_validation", [True]) @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) +@pytest.mark.usefixtures("recorder_mock") async def test_validate_db_schema( hass: HomeAssistant, - recorder_mock: Recorder, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index d381c225275..0c1ad43823d 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -11,10 +11,12 @@ from functools import partial import importlib import sys import time +from types import ModuleType from typing import Any, Literal, cast from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time +import pytest from sqlalchemy import create_engine, event as sqlalchemy_event from sqlalchemy.orm.session import Session @@ -28,18 +30,25 @@ from homeassistant.components.recorder import ( statistics, ) from homeassistant.components.recorder.db_schema import ( + EventData, Events, EventTypes, RecorderRuns, + StateAttributes, States, StatesMeta, ) +from homeassistant.components.recorder.models import ( + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, +) from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import DEGREE, UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import recorder as recorder_helper from homeassistant.util import dt as dt_util +from homeassistant.util.json import json_loads, json_loads_object from . import db_schema_0 @@ -452,6 +461,13 @@ def get_schema_module_path(schema_version_postfix: str) -> str: return f"tests.components.recorder.db_schema_{schema_version_postfix}" +def get_patched_live_version(old_db_schema: ModuleType) -> int: + """Return the patched live migration version.""" + return min( + migration.LIVE_MIGRATION_MIN_SCHEMA_VERSION, old_db_schema.SCHEMA_VERSION + ) + + @contextmanager def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" @@ -462,6 +478,11 @@ def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[ with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), @@ -492,3 +513,97 @@ async def async_attach_db_engine(hass: HomeAssistant) -> None: ) await instance.async_add_executor_job(_mock_setup_recorder_connection) + + +EVENT_ORIGIN_ORDER = [ha.EventOrigin.local, ha.EventOrigin.remote] + + +def db_event_to_native(event: Events, validate_entity_id: bool = True) -> Event | None: + """Convert to a native HA Event.""" + context = ha.Context( + id=bytes_to_ulid_or_none(event.context_id_bin), + user_id=bytes_to_uuid_hex_or_none(event.context_user_id_bin), + parent_id=bytes_to_ulid_or_none(event.context_parent_id_bin), + ) + return Event( + event.event_type or "", + json_loads_object(event.event_data) if event.event_data else {}, + ha.EventOrigin(event.origin) + if event.origin + else EVENT_ORIGIN_ORDER[event.origin_idx or 0], + event.time_fired_ts or 0, + context=context, + ) + + +def db_event_data_to_native(event_data: EventData) -> dict[str, Any]: + """Convert to an event data dictionary.""" + shared_data = event_data.shared_data + if shared_data is None: + return {} + return cast(dict[str, Any], json_loads(shared_data)) + + +def db_state_to_native(state: States, validate_entity_id: bool = True) -> State | None: + """Convert to an HA state object.""" + context = ha.Context( + id=bytes_to_ulid_or_none(state.context_id_bin), + user_id=bytes_to_uuid_hex_or_none(state.context_user_id_bin), + parent_id=bytes_to_ulid_or_none(state.context_parent_id_bin), + ) + attrs = json_loads_object(state.attributes) if state.attributes else {} + last_updated = dt_util.utc_from_timestamp(state.last_updated_ts or 0) + if state.last_changed_ts is None or state.last_changed_ts == state.last_updated_ts: + last_changed = dt_util.utc_from_timestamp(state.last_updated_ts or 0) + else: + last_changed = dt_util.utc_from_timestamp(state.last_changed_ts or 0) + if ( + state.last_reported_ts is None + or state.last_reported_ts == state.last_updated_ts + ): + last_reported = dt_util.utc_from_timestamp(state.last_updated_ts or 0) + else: + last_reported = dt_util.utc_from_timestamp(state.last_reported_ts or 0) + return State( + state.entity_id or "", + state.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, + ) + + +def db_state_attributes_to_native(state_attrs: StateAttributes) -> dict[str, Any]: + """Convert to a state attributes dictionary.""" + shared_attrs = state_attrs.shared_attrs + if shared_attrs is None: + return {} + return cast(dict[str, Any], json_loads(shared_attrs)) + + +async def async_drop_index( + recorder: Recorder, table: str, index: str, caplog: pytest.LogCaptureFixture +) -> None: + """Drop an index from the database. + + migration._drop_index does not return or raise, so we verify the result + by checking the log for success or failure messages. + """ + + finish_msg = f"Finished dropping index `{index}` from table `{table}`" + fail_msg = f"Failed to drop index `{index}` from table `{table}`" + + count_finish = caplog.text.count(finish_msg) + count_fail = caplog.text.count(fail_msg) + + await recorder.async_add_executor_job( + migration._drop_index, recorder.get_session, table, index + ) + + assert caplog.text.count(finish_msg) == count_finish + 1 + assert caplog.text.count(fail_msg) == count_fail diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 9c19a1c7405..cf49a3f5e97 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -414,10 +414,9 @@ class States(Base): # type: ignore[misc,valid-type] @staticmethod def from_event(event: Event) -> States: """Create object from a state_changed event.""" - entity_id = event.data["entity_id"] state: State | None = event.data.get("new_state") dbstate = States( - entity_id=entity_id, + entity_id=None, attributes=None, context_id=event.context.id, context_user_id=event.context.user_id, diff --git a/tests/components/recorder/db_schema_43.py b/tests/components/recorder/db_schema_43.py index 379e6fbd416..31e837d6bb6 100644 --- a/tests/components/recorder/db_schema_43.py +++ b/tests/components/recorder/db_schema_43.py @@ -519,7 +519,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), diff --git a/tests/components/recorder/db_schema_42.py b/tests/components/recorder/db_schema_48.py similarity index 77% rename from tests/components/recorder/db_schema_42.py rename to tests/components/recorder/db_schema_48.py index a5381d633cb..43587bd966d 100644 --- a/tests/components/recorder/db_schema_42.py +++ b/tests/components/recorder/db_schema_48.py @@ -1,6 +1,6 @@ """Models for SQLAlchemy. -This file contains the model definitions for schema version 42. +This file contains the model definitions for schema version 48. It is used to test the schema migration logic. """ @@ -10,7 +10,7 @@ from collections.abc import Callable from datetime import datetime, timedelta import logging import time -from typing import Any, Self, cast +from typing import Any, Final, Self, cast import ciso8601 from fnv_hash_fast import fnv1a_32 @@ -64,7 +64,7 @@ from homeassistant.const import ( MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, ) -from homeassistant.core import Context, Event, EventOrigin, State +from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State 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 ( @@ -79,7 +79,11 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 42 +class LegacyBase(DeclarativeBase): + """Base class for tables, used for schema migration.""" + + +SCHEMA_VERSION = 48 _LOGGER = logging.getLogger(__name__) @@ -95,6 +99,7 @@ TABLE_STATISTICS = "statistics" TABLE_STATISTICS_META = "statistics_meta" TABLE_STATISTICS_RUNS = "statistics_runs" TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" +TABLE_MIGRATION_CHANGES = "migration_changes" STATISTICS_TABLES = ("statistics", "statistics_short_term") @@ -111,6 +116,7 @@ ALL_TABLES = [ TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, + TABLE_MIGRATION_CHANGES, TABLE_STATES_META, TABLE_STATISTICS, TABLE_STATISTICS_META, @@ -130,7 +136,8 @@ METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts" EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" -LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated_ts" +LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_updated_ts" +LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36 CONTEXT_ID_BIN_MAX_LENGTH = 16 MYSQL_COLLATE = "utf8mb4_unicode_ci" @@ -146,6 +153,13 @@ _DEFAULT_TABLE_ARGS = { "mariadb_engine": MYSQL_ENGINE, } +_MATCH_ALL_KEEP = { + ATTR_DEVICE_CLASS, + ATTR_STATE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + ATTR_FRIENDLY_NAME, +} + class UnusedDateTime(DateTime): """An unused column type that behaves like a datetime.""" @@ -155,14 +169,14 @@ class Unused(CHAR): """An unused column type that behaves like a string.""" -@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] -@compiles(Unused, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] +@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") +@compiles(Unused, "mysql", "mariadb", "sqlite") def compile_char_zero(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: """Compile UnusedDateTime and Unused as CHAR(0) on mysql, mariadb, and sqlite.""" return "CHAR(0)" # Uses 1 byte on MySQL (no change on sqlite) -@compiles(Unused, "postgresql") # type: ignore[misc,no-untyped-call] +@compiles(Unused, "postgresql") def compile_char_one(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: """Compile Unused as CHAR(1) on postgresql.""" return "CHAR(1)" # Uses 1 byte @@ -184,6 +198,9 @@ class NativeLargeBinary(LargeBinary): return None +# Although all integers are same in SQLite, it does not allow an identity column to be BIGINT +# https://sqlite.org/forum/info/2dfa968a702e1506e885cb06d92157d492108b22bf39459506ab9f7125bca7fd +ID_TYPE = BigInteger().with_variant(sqlite.INTEGER, "sqlite") # For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32 # for sqlite and postgresql we use a bigint UINT_32_TYPE = BigInteger().with_variant( @@ -214,6 +231,7 @@ UNUSED_LEGACY_COLUMN = Unused(0) UNUSED_LEGACY_DATETIME_COLUMN = UnusedDateTime(timezone=True) UNUSED_LEGACY_INTEGER_COLUMN = SmallInteger() DOUBLE_PRECISION_TYPE_SQL = "DOUBLE PRECISION" +BIG_INTEGER_SQL = "BIGINT" CONTEXT_BINARY_TYPE = LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH).with_variant( NativeLargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), "mysql", "mariadb", "sqlite" ) @@ -235,7 +253,6 @@ class JSONLiteral(JSON): EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] -EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} class Events(Base): @@ -256,7 +273,7 @@ class Events(Base): _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_EVENTS - event_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + event_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) event_type: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) event_data: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) origin: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) @@ -267,13 +284,13 @@ class Events(Base): context_user_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) context_parent_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) data_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("event_data.data_id"), index=True + ID_TYPE, ForeignKey("event_data.data_id"), index=True ) context_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) context_user_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) context_parent_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) event_type_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("event_types.event_type_id") + ID_TYPE, ForeignKey("event_types.event_type_id") ) event_data_rel: Mapped[EventData | None] = relationship("EventData") event_type_rel: Mapped[EventTypes | None] = relationship("EventTypes") @@ -302,18 +319,19 @@ class Events(Base): @staticmethod def from_event(event: Event) -> Events: """Create an event database object from a native event.""" + context = event.context return Events( event_type=None, event_data=None, - origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), + origin_idx=event.origin.idx, time_fired=None, time_fired_ts=event.time_fired_timestamp, context_id=None, - context_id_bin=ulid_to_bytes_or_none(event.context.id), + context_id_bin=ulid_to_bytes_or_none(context.id), context_user_id=None, - context_user_id_bin=uuid_hex_to_bytes_or_none(event.context.user_id), + context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id=None, - context_parent_id_bin=ulid_to_bytes_or_none(event.context.parent_id), + context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), ) def to_native(self, validate_entity_id: bool = True) -> Event | None: @@ -330,7 +348,7 @@ class Events(Base): EventOrigin(self.origin) if self.origin else EVENT_ORIGIN_ORDER[self.origin_idx or 0], - dt_util.utc_from_timestamp(self.time_fired_ts or 0), + self.time_fired_ts or 0, context=context, ) except JSON_DECODE_EXCEPTIONS: @@ -339,12 +357,23 @@ class Events(Base): return None +class LegacyEvents(LegacyBase): + """Event history data with event_id, used for schema migration.""" + + __table_args__ = (_DEFAULT_TABLE_ARGS,) + __tablename__ = TABLE_EVENTS + event_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) + context_id: Mapped[str | None] = mapped_column( + String(LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID), index=True + ) + + class EventData(Base): """Event data history.""" __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_EVENT_DATA - data_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + data_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) hash: Mapped[int | None] = mapped_column(UINT_32_TYPE, index=True) # Note that this is not named attributes to avoid confusion with the states table shared_data: Mapped[str | None] = mapped_column( @@ -364,9 +393,8 @@ class EventData(Base): event: Event, dialect: SupportedDialect | None ) -> bytes: """Create shared_data from an event.""" - if dialect == SupportedDialect.POSTGRESQL: - bytes_result = json_bytes_strip_null(event.data) - bytes_result = json_bytes(event.data) + encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes + bytes_result = encoder(event.data) if len(bytes_result) > MAX_EVENT_DATA_BYTES: _LOGGER.warning( "Event data for %s exceed maximum size of %s bytes. " @@ -400,7 +428,7 @@ class EventTypes(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_EVENT_TYPES - event_type_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + event_type_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) event_type: Mapped[str | None] = mapped_column( String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True, unique=True ) @@ -430,22 +458,23 @@ class States(Base): _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_STATES - state_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + state_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) entity_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) state: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_STATE)) attributes: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) event_id: Mapped[int | None] = mapped_column(UNUSED_LEGACY_INTEGER_COLUMN) last_changed: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) last_changed_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE) + last_reported_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE) last_updated: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) last_updated_ts: Mapped[float | None] = mapped_column( TIMESTAMP_TYPE, default=time.time, index=True ) old_state_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("states.state_id"), index=True + ID_TYPE, ForeignKey("states.state_id"), index=True ) attributes_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("state_attributes.attributes_id"), index=True + ID_TYPE, ForeignKey("state_attributes.attributes_id"), index=True ) context_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) context_user_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) @@ -459,7 +488,7 @@ class States(Base): context_user_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) context_parent_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) metadata_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("states_meta.metadata_id") + ID_TYPE, ForeignKey("states_meta.metadata_id") ) states_meta_rel: Mapped[StatesMeta | None] = relationship("StatesMeta") @@ -486,38 +515,44 @@ class States(Base): return date_time.isoformat(sep=" ", timespec="seconds") @staticmethod - def from_event(event: Event) -> States: + def from_event(event: Event[EventStateChangedData]) -> States: """Create object from a state_changed event.""" - entity_id = event.data["entity_id"] - state: State | None = event.data.get("new_state") - dbstate = States( - entity_id=entity_id, - attributes=None, - context_id=None, - context_id_bin=ulid_to_bytes_or_none(event.context.id), - context_user_id=None, - context_user_id_bin=uuid_hex_to_bytes_or_none(event.context.user_id), - context_parent_id=None, - context_parent_id_bin=ulid_to_bytes_or_none(event.context.parent_id), - origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), - last_updated=None, - last_changed=None, - ) + state = event.data["new_state"] # None state means the state was removed from the state machine if state is None: - dbstate.state = "" - dbstate.last_updated_ts = event.time_fired_timestamp - dbstate.last_changed_ts = None - return dbstate - - dbstate.state = state.state - dbstate.last_updated_ts = state.last_updated_timestamp - if state.last_updated == state.last_changed: - dbstate.last_changed_ts = None + state_value = "" + last_updated_ts = event.time_fired_timestamp + last_changed_ts = None + last_reported_ts = None else: - dbstate.last_changed_ts = state.last_changed_timestamp - - return dbstate + state_value = state.state + last_updated_ts = state.last_updated_timestamp + if state.last_updated == state.last_changed: + last_changed_ts = None + else: + last_changed_ts = state.last_changed_timestamp + if state.last_updated == state.last_reported: + last_reported_ts = None + else: + last_reported_ts = state.last_reported_timestamp + context = event.context + return States( + state=state_value, + entity_id=event.data["entity_id"], + attributes=None, + context_id=None, + context_id_bin=ulid_to_bytes_or_none(context.id), + context_user_id=None, + context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), + context_parent_id=None, + context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), + origin_idx=event.origin.idx, + last_updated=None, + last_changed=None, + last_updated_ts=last_updated_ts, + last_changed_ts=last_changed_ts, + last_reported_ts=last_reported_ts, + ) def to_native(self, validate_entity_id: bool = True) -> State | None: """Convert to an HA state object.""" @@ -532,32 +567,60 @@ class States(Base): # 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 = last_updated = dt_util.utc_from_timestamp( - self.last_updated_ts or 0 - ) + last_changed = dt_util.utc_from_timestamp(self.last_updated_ts or 0) else: - last_updated = dt_util.utc_from_timestamp(self.last_updated_ts or 0) 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_updated, + 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.""" + + __table_args__ = ( + Index( + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX, + "entity_id", + "last_updated_ts", + ), + _DEFAULT_TABLE_ARGS, + ) + __tablename__ = TABLE_STATES + state_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) + entity_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + last_updated_ts: Mapped[float | None] = mapped_column( + TIMESTAMP_TYPE, default=time.time, index=True + ) + context_id: Mapped[str | None] = mapped_column( + String(LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID), index=True + ) + + class StateAttributes(Base): """State attribute change history.""" __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATE_ATTRIBUTES - attributes_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + attributes_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) hash: Mapped[int | None] = mapped_column(UINT_32_TYPE, index=True) # Note that this is not named attributes to avoid confusion with the states table shared_attrs: Mapped[str | None] = mapped_column( @@ -573,13 +636,12 @@ class StateAttributes(Base): @staticmethod def shared_attrs_bytes_from_event( - event: Event, + event: Event[EventStateChangedData], dialect: SupportedDialect | None, ) -> bytes: """Create shared_attrs from a state_changed event.""" - state: State | None = event.data.get("new_state") # None state means the state was removed from the state machine - if state is None: + if (state := event.data["new_state"]) is None: return b"{}" if state_info := state.state_info: unrecorded_attributes = state_info["unrecorded_attributes"] @@ -590,19 +652,8 @@ class StateAttributes(Base): if MATCH_ALL in unrecorded_attributes: # Don't exclude device class, state class, unit of measurement # or friendly name when using the MATCH_ALL exclude constant - _exclude_attributes = { - k: v - for k, v in state.attributes.items() - if k - not in ( - ATTR_DEVICE_CLASS, - ATTR_STATE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, - ATTR_FRIENDLY_NAME, - ) - } - exclude_attrs.update(_exclude_attributes) - + exclude_attrs.update(state.attributes) + exclude_attrs -= _MATCH_ALL_KEEP else: exclude_attrs = ALL_DOMAIN_EXCLUDE_ATTRS encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes @@ -643,7 +694,7 @@ class StatesMeta(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATES_META - metadata_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + metadata_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) entity_id: Mapped[str | None] = mapped_column( String(MAX_LENGTH_STATE_ENTITY_ID), index=True, unique=True ) @@ -660,11 +711,11 @@ class StatesMeta(Base): class StatisticsBase: """Statistics base class.""" - id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) created: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) created_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, default=time.time) metadata_id: Mapped[int | None] = mapped_column( - Integer, + ID_TYPE, ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), ) start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) @@ -680,12 +731,14 @@ class StatisticsBase: duration: timedelta @classmethod - def from_stats(cls, metadata_id: int, stats: StatisticData) -> Self: - """Create object from a statistics with datatime objects.""" + def from_stats( + cls, metadata_id: int, stats: StatisticData, now_timestamp: float | None = None + ) -> Self: + """Create object from a statistics with datetime objects.""" return cls( # type: ignore[call-arg] metadata_id=metadata_id, created=None, - created_ts=time.time(), + created_ts=now_timestamp or time.time(), start=None, start_ts=stats["start"].timestamp(), mean=stats.get("mean"), @@ -698,12 +751,17 @@ class StatisticsBase: ) @classmethod - def from_stats_ts(cls, metadata_id: int, stats: StatisticDataTimestamp) -> Self: + def from_stats_ts( + cls, + metadata_id: int, + stats: StatisticDataTimestamp, + now_timestamp: float | None = None, + ) -> Self: """Create object from a statistics with timestamps.""" return cls( # type: ignore[call-arg] metadata_id=metadata_id, created=None, - created_ts=time.time(), + created_ts=now_timestamp or time.time(), start=None, start_ts=stats["start_ts"], mean=stats.get("mean"), @@ -729,15 +787,22 @@ class Statistics(Base, StatisticsBase): "start_ts", unique=True, ), + _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_STATISTICS -class StatisticsShortTerm(Base, StatisticsBase): +class _StatisticsShortTerm(StatisticsBase): """Short term statistics.""" duration = timedelta(minutes=5) + __tablename__ = TABLE_STATISTICS_SHORT_TERM + + +class StatisticsShortTerm(Base, _StatisticsShortTerm): + """Short term statistics.""" + __table_args__ = ( # Used for fetching statistics for a certain entity at a specific time Index( @@ -746,16 +811,37 @@ class StatisticsShortTerm(Base, StatisticsBase): "start_ts", unique=True, ), + _DEFAULT_TABLE_ARGS, ) - __tablename__ = TABLE_STATISTICS_SHORT_TERM -class StatisticsMeta(Base): +class LegacyStatisticsShortTerm(LegacyBase, _StatisticsShortTerm): + """Short term statistics with 32-bit index, used for schema migration.""" + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index( + "ix_statistics_short_term_statistic_id_start_ts", + "metadata_id", + "start_ts", + unique=True, + ), + _DEFAULT_TABLE_ARGS, + ) + + metadata_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + use_existing_column=True, + ) + + +class _StatisticsMeta: """Statistics meta data.""" __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATISTICS_META - id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) statistic_id: Mapped[str | None] = mapped_column( String(255), index=True, unique=True ) @@ -771,12 +857,30 @@ class StatisticsMeta(Base): return StatisticsMeta(**meta) +class StatisticsMeta(Base, _StatisticsMeta): + """Statistics meta data.""" + + +class LegacyStatisticsMeta(LegacyBase, _StatisticsMeta): + """Statistics meta data with 32-bit index, used for schema migration.""" + + id: Mapped[int] = mapped_column( + Integer, + Identity(), + primary_key=True, + use_existing_column=True, + ) + + class RecorderRuns(Base): """Representation of recorder run.""" - __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) + __table_args__ = ( + Index("ix_recorder_runs_start_end", "start", "end"), + _DEFAULT_TABLE_ARGS, + ) __tablename__ = TABLE_RECORDER_RUNS - run_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + run_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) start: Mapped[datetime] = mapped_column(DATETIME_TYPE, default=dt_util.utcnow) end: Mapped[datetime | None] = mapped_column(DATETIME_TYPE) closed_incorrect: Mapped[bool] = mapped_column(Boolean, default=False) @@ -799,11 +903,23 @@ class RecorderRuns(Base): return self +class MigrationChanges(Base): + """Representation of migration changes.""" + + __tablename__ = TABLE_MIGRATION_CHANGES + __table_args__ = (_DEFAULT_TABLE_ARGS,) + + migration_id: Mapped[str] = mapped_column(String(255), primary_key=True) + version: Mapped[int] = mapped_column(SmallInteger) + + class SchemaChanges(Base): """Representation of schema version changes.""" __tablename__ = TABLE_SCHEMA_CHANGES - change_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + __table_args__ = (_DEFAULT_TABLE_ARGS,) + + change_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) schema_version: Mapped[int | None] = mapped_column(Integer) changed: Mapped[datetime] = mapped_column(DATETIME_TYPE, default=dt_util.utcnow) @@ -821,7 +937,9 @@ class StatisticsRuns(Base): """Representation of statistics run.""" __tablename__ = TABLE_STATISTICS_RUNS - run_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + __table_args__ = (_DEFAULT_TABLE_ARGS,) + + run_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) start: Mapped[datetime] = mapped_column(DATETIME_TYPE, index=True) def __repr__(self) -> str: diff --git a/tests/components/recorder/table_managers/test_recorder_runs.py b/tests/components/recorder/table_managers/test_recorder_runs.py index e79def01bad..3567b57750f 100644 --- a/tests/components/recorder/table_managers/test_recorder_runs.py +++ b/tests/components/recorder/table_managers/test_recorder_runs.py @@ -3,8 +3,9 @@ from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.models import process_timestamp from homeassistant.core import HomeAssistant @@ -13,7 +14,8 @@ from homeassistant.util import dt as dt_util from tests.typing import RecorderInstanceGenerator -async def test_run_history(recorder_mock: Recorder, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_run_history(hass: HomeAssistant) -> None: """Test the run history gives the correct run.""" instance = recorder.get_instance(hass) now = dt_util.utcnow() diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index a4362b1fa4c..22db04c5076 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -5,13 +5,13 @@ from unittest.mock import patch import pytest -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.backup import async_post_backup, async_pre_backup from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError -async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_async_pre_backup(hass: HomeAssistant) -> None: """Test pre backup.""" with patch( "homeassistant.components.recorder.core.Recorder.lock_database" @@ -36,8 +36,8 @@ RAISES_HASS_NOT_RUNNING = pytest.raises( (CoreState.stopping, RAISES_HASS_NOT_RUNNING, 0), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_async_pre_backup_core_state( - recorder_mock: Recorder, hass: HomeAssistant, core_state: CoreState, expected_result: AbstractContextManager, @@ -55,9 +55,8 @@ async def test_async_pre_backup_core_state( assert len(lock_mock.mock_calls) == lock_calls -async def test_async_pre_backup_with_timeout( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_async_pre_backup_with_timeout(hass: HomeAssistant) -> None: """Test pre backup with timeout.""" with ( patch( @@ -70,9 +69,8 @@ async def test_async_pre_backup_with_timeout( assert lock_mock.called -async def test_async_pre_backup_with_migration( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_async_pre_backup_with_migration(hass: HomeAssistant) -> None: """Test pre backup with migration.""" with ( patch( @@ -88,7 +86,8 @@ async def test_async_pre_backup_with_migration( assert not lock_mock.called -async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_async_post_backup(hass: HomeAssistant) -> None: """Test post backup.""" with patch( "homeassistant.components.recorder.core.Recorder.unlock_database" @@ -97,9 +96,8 @@ async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) - assert unlock_mock.called -async def test_async_post_backup_failure( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_async_post_backup_failure(hass: HomeAssistant) -> None: """Test post backup failure.""" with ( patch( diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py index 97839803619..421039bcbb1 100644 --- a/tests/components/recorder/test_filters_with_entityfilter.py +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -2,10 +2,11 @@ import json +import pytest from sqlalchemy import select from sqlalchemy.engine.row import Row -from homeassistant.components.recorder import Recorder, get_instance +from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.db_schema import EventData, Events, StatesMeta from homeassistant.components.recorder.filters import ( Filters, @@ -75,8 +76,9 @@ async def _async_get_states_and_events_with_filter( return filtered_states_entity_ids, filtered_events_entity_ids +@pytest.mark.usefixtures("recorder_mock") async def test_included_and_excluded_simple_case_no_domains( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test filters with included and excluded without domains.""" filter_accept = {"sensor.kitchen4", "switch.kitchen"} @@ -133,9 +135,8 @@ async def test_included_and_excluded_simple_case_no_domains( assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_and_excluded_simple_case_no_globs( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_included_and_excluded_simple_case_no_globs(hass: HomeAssistant) -> None: """Test filters with included and excluded without globs.""" filter_accept = {"switch.bla", "sensor.blu", "sensor.keep"} filter_reject = {"sensor.bli"} @@ -175,8 +176,9 @@ async def test_included_and_excluded_simple_case_no_globs( assert not filtered_events_entity_ids.intersection(filter_reject) +@pytest.mark.usefixtures("recorder_mock") async def test_included_and_excluded_simple_case_without_underscores( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test filters with included and excluded without underscores.""" filter_accept = {"light.any", "sensor.kitchen4", "switch.kitchen"} @@ -229,8 +231,9 @@ async def test_included_and_excluded_simple_case_without_underscores( assert not filtered_events_entity_ids.intersection(filter_reject) +@pytest.mark.usefixtures("recorder_mock") async def test_included_and_excluded_simple_case_with_underscores( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test filters with included and excluded with underscores.""" filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} @@ -283,9 +286,8 @@ async def test_included_and_excluded_simple_case_with_underscores( assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_and_excluded_complex_case( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_included_and_excluded_complex_case(hass: HomeAssistant) -> None: """Test filters with included and excluded with a complex filter.""" filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} filter_reject = { @@ -342,9 +344,8 @@ async def test_included_and_excluded_complex_case( assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_entities_and_excluded_domain( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_included_entities_and_excluded_domain(hass: HomeAssistant) -> None: """Test filters with included entities and excluded domain.""" filter_accept = { "media_player.test", @@ -390,9 +391,8 @@ async def test_included_entities_and_excluded_domain( assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_same_domain_included_excluded( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_same_domain_included_excluded(hass: HomeAssistant) -> None: """Test filters with the same domain included and excluded.""" filter_accept = { "media_player.test", @@ -438,9 +438,8 @@ async def test_same_domain_included_excluded( assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_same_entity_included_excluded( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_same_entity_included_excluded(hass: HomeAssistant) -> None: """Test filters with the same entity included and excluded.""" filter_accept = { "media_player.test", @@ -486,8 +485,9 @@ async def test_same_entity_included_excluded( assert not filtered_events_entity_ids.intersection(filter_reject) +@pytest.mark.usefixtures("recorder_mock") async def test_same_entity_included_excluded_include_domain_wins( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test filters with domain and entities and the include domain wins.""" filter_accept = { @@ -536,9 +536,8 @@ async def test_same_entity_included_excluded_include_domain_wins( assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_specificly_included_entity_always_wins( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_specificly_included_entity_always_wins(hass: HomeAssistant) -> None: """Test specifically included entity always wins.""" filter_accept = { "media_player.test2", @@ -586,8 +585,9 @@ async def test_specificly_included_entity_always_wins( assert not filtered_events_entity_ids.intersection(filter_reject) +@pytest.mark.usefixtures("recorder_mock") async def test_specificly_included_entity_always_wins_over_glob( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test specifically included entity always wins over a glob.""" filter_accept = { diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py deleted file mode 100644 index 2e9883aaf53..00000000000 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ /dev/null @@ -1,694 +0,0 @@ -"""The tests for the recorder filter matching the EntityFilter component.""" - -from collections.abc import AsyncGenerator, Generator -import json -from unittest.mock import patch - -import pytest -from sqlalchemy import select -from sqlalchemy.engine.row import Row - -from homeassistant.components.recorder import Recorder, get_instance -from homeassistant.components.recorder.db_schema import EventData, Events, States -from homeassistant.components.recorder.filters import ( - Filters, - extract_include_exclude_filter_conf, - sqlalchemy_filter_from_include_exclude_conf, -) -from homeassistant.components.recorder.util import session_scope -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_DOMAINS, - CONF_ENTITIES, - CONF_EXCLUDE, - CONF_INCLUDE, - STATE_ON, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entityfilter import ( - CONF_ENTITY_GLOBS, - convert_include_exclude_filter, -) - -from .common import async_wait_recording_done, old_db_schema - -from tests.typing import RecorderInstanceContextManager - - -@pytest.fixture -async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceContextManager, -) -> None: - """Set up recorder.""" - - -# This test is for schema 37 and below (32 is new enough to test) -@pytest.fixture(autouse=True) -def db_schema_32(hass: HomeAssistant) -> Generator[None]: - """Fixture to initialize the db with the old schema 32.""" - with old_db_schema(hass, "32"): - yield - - -@pytest.fixture(name="legacy_recorder_mock") -async def legacy_recorder_mock_fixture( - recorder_mock: Recorder, -) -> AsyncGenerator[Recorder]: - """Fixture for legacy recorder mock.""" - with patch.object(recorder_mock.states_meta_manager, "active", False): - yield recorder_mock - - -async def _async_get_states_and_events_with_filter( - hass: HomeAssistant, sqlalchemy_filter: Filters, entity_ids: set[str] -) -> tuple[list[Row], list[Row]]: - """Get states from the database based on a filter.""" - for entity_id in entity_ids: - hass.states.async_set(entity_id, STATE_ON) - hass.bus.async_fire("any", {ATTR_ENTITY_ID: entity_id}) - - await async_wait_recording_done(hass) - - def _get_states_with_session(): - with session_scope(hass=hass) as session: - return session.execute( - select(States.entity_id).filter( - sqlalchemy_filter.states_entity_filter() - ) - ).all() - - filtered_states_entity_ids = { - row[0] - for row in await get_instance(hass).async_add_executor_job( - _get_states_with_session - ) - } - - def _get_events_with_session(): - with session_scope(hass=hass) as session: - return session.execute( - select(EventData.shared_data) - .outerjoin(Events, EventData.data_id == Events.data_id) - .filter(sqlalchemy_filter.events_entity_filter()) - ).all() - - filtered_events_entity_ids = set() - for row in await get_instance(hass).async_add_executor_job( - _get_events_with_session - ): - event_data = json.loads(row[0]) - if ATTR_ENTITY_ID not in event_data: - continue - filtered_events_entity_ids.add(json.loads(row[0])[ATTR_ENTITY_ID]) - - return filtered_states_entity_ids, filtered_events_entity_ids - - -async def test_included_and_excluded_simple_case_no_domains( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with included and excluded without domains.""" - filter_accept = {"sensor.kitchen4", "switch.kitchen"} - filter_reject = { - "light.any", - "switch.other", - "cover.any", - "sensor.weather5", - "light.kitchen", - } - conf = { - CONF_INCLUDE: { - CONF_ENTITY_GLOBS: ["sensor.kitchen*"], - CONF_ENTITIES: ["switch.kitchen"], - }, - CONF_EXCLUDE: { - CONF_ENTITY_GLOBS: ["sensor.weather*"], - CONF_ENTITIES: ["light.kitchen"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - assert not entity_filter.explicitly_included("light.any") - assert not entity_filter.explicitly_included("switch.other") - assert entity_filter.explicitly_included("sensor.kitchen4") - assert entity_filter.explicitly_included("switch.kitchen") - - assert not entity_filter.explicitly_excluded("light.any") - assert not entity_filter.explicitly_excluded("switch.other") - assert entity_filter.explicitly_excluded("sensor.weather5") - assert entity_filter.explicitly_excluded("light.kitchen") - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_included_and_excluded_simple_case_no_globs( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with included and excluded without globs.""" - filter_accept = {"switch.bla", "sensor.blu", "sensor.keep"} - filter_reject = {"sensor.bli"} - conf = { - CONF_INCLUDE: { - CONF_DOMAINS: ["sensor", "homeassistant"], - CONF_ENTITIES: ["switch.bla"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["switch"], - CONF_ENTITIES: ["sensor.bli"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_included_and_excluded_simple_case_without_underscores( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with included and excluded without underscores.""" - filter_accept = {"light.any", "sensor.kitchen4", "switch.kitchen"} - filter_reject = {"switch.other", "cover.any", "sensor.weather5", "light.kitchen"} - conf = { - CONF_INCLUDE: { - CONF_DOMAINS: ["light"], - CONF_ENTITY_GLOBS: ["sensor.kitchen*"], - CONF_ENTITIES: ["switch.kitchen"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["cover"], - CONF_ENTITY_GLOBS: ["sensor.weather*"], - CONF_ENTITIES: ["light.kitchen"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - assert not entity_filter.explicitly_included("light.any") - assert not entity_filter.explicitly_included("switch.other") - assert entity_filter.explicitly_included("sensor.kitchen4") - assert entity_filter.explicitly_included("switch.kitchen") - - assert not entity_filter.explicitly_excluded("light.any") - assert not entity_filter.explicitly_excluded("switch.other") - assert entity_filter.explicitly_excluded("sensor.weather5") - assert entity_filter.explicitly_excluded("light.kitchen") - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_included_and_excluded_simple_case_with_underscores( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with included and excluded with underscores.""" - filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} - filter_reject = {"switch.other", "cover.any", "sensor.weather_5", "light.kitchen"} - conf = { - CONF_INCLUDE: { - CONF_DOMAINS: ["light"], - CONF_ENTITY_GLOBS: ["sensor.kitchen_*"], - CONF_ENTITIES: ["switch.kitchen"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["cover"], - CONF_ENTITY_GLOBS: ["sensor.weather_*"], - CONF_ENTITIES: ["light.kitchen"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - assert not entity_filter.explicitly_included("light.any") - assert not entity_filter.explicitly_included("switch.other") - assert entity_filter.explicitly_included("sensor.kitchen_4") - assert entity_filter.explicitly_included("switch.kitchen") - - assert not entity_filter.explicitly_excluded("light.any") - assert not entity_filter.explicitly_excluded("switch.other") - assert entity_filter.explicitly_excluded("sensor.weather_5") - assert entity_filter.explicitly_excluded("light.kitchen") - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_included_and_excluded_complex_case( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with included and excluded with a complex filter.""" - filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} - filter_reject = { - "camera.one", - "notify.any", - "automation.update_readme", - "automation.update_utilities_cost", - "binary_sensor.iss", - } - conf = { - CONF_INCLUDE: { - CONF_ENTITIES: ["group.trackers"], - }, - CONF_EXCLUDE: { - CONF_ENTITIES: [ - "automation.update_readme", - "automation.update_utilities_cost", - "binary_sensor.iss", - ], - CONF_DOMAINS: [ - "camera", - "group", - "media_player", - "notify", - "scene", - "sun", - "zone", - ], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_included_entities_and_excluded_domain( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with included entities and excluded domain.""" - filter_accept = { - "media_player.test", - "media_player.test3", - "thermostat.test", - "zone.home", - "script.can_cancel_this_one", - } - filter_reject = { - "thermostat.test2", - } - conf = { - CONF_INCLUDE: { - CONF_ENTITIES: ["media_player.test", "thermostat.test"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["thermostat"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_same_domain_included_excluded( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with the same domain included and excluded.""" - filter_accept = { - "media_player.test", - "media_player.test3", - } - filter_reject = { - "thermostat.test2", - "thermostat.test", - "zone.home", - "script.can_cancel_this_one", - } - conf = { - CONF_INCLUDE: { - CONF_DOMAINS: ["media_player"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["media_player"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_same_entity_included_excluded( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with the same entity included and excluded.""" - filter_accept = { - "media_player.test", - } - filter_reject = { - "media_player.test3", - "thermostat.test2", - "thermostat.test", - "zone.home", - "script.can_cancel_this_one", - } - conf = { - CONF_INCLUDE: { - CONF_ENTITIES: ["media_player.test"], - }, - CONF_EXCLUDE: { - CONF_ENTITIES: ["media_player.test"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_same_entity_included_excluded_include_domain_wins( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test filters with domain and entities and the include domain wins.""" - filter_accept = { - "media_player.test2", - "media_player.test3", - "thermostat.test", - } - filter_reject = { - "thermostat.test2", - "zone.home", - "script.can_cancel_this_one", - } - conf = { - CONF_INCLUDE: { - CONF_DOMAINS: ["media_player"], - CONF_ENTITIES: ["thermostat.test"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["thermostat"], - CONF_ENTITIES: ["media_player.test"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_specificly_included_entity_always_wins( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test specifically included entity always wins.""" - filter_accept = { - "media_player.test2", - "media_player.test3", - "thermostat.test", - "binary_sensor.specific_include", - } - filter_reject = { - "binary_sensor.test2", - "binary_sensor.home", - "binary_sensor.can_cancel_this_one", - } - conf = { - CONF_INCLUDE: { - CONF_ENTITIES: ["binary_sensor.specific_include"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["binary_sensor"], - CONF_ENTITY_GLOBS: ["binary_sensor.*"], - }, - } - - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) - - -async def test_specificly_included_entity_always_wins_over_glob( - legacy_recorder_mock: Recorder, hass: HomeAssistant -) -> None: - """Test specifically included entity always wins over a glob.""" - filter_accept = { - "sensor.apc900va_status", - "sensor.apc900va_battery_charge", - "sensor.apc900va_battery_runtime", - "sensor.apc900va_load", - "sensor.energy_x", - } - filter_reject = { - "sensor.apc900va_not_included", - } - conf = { - CONF_EXCLUDE: { - CONF_DOMAINS: [ - "updater", - "camera", - "group", - "media_player", - "script", - "sun", - "automation", - "zone", - "weblink", - "scene", - "calendar", - "weather", - "remote", - "notify", - "switch", - "shell_command", - "media_player", - ], - CONF_ENTITY_GLOBS: ["sensor.apc900va_*"], - }, - CONF_INCLUDE: { - CONF_DOMAINS: [ - "binary_sensor", - "climate", - "device_tracker", - "input_boolean", - "sensor", - ], - CONF_ENTITY_GLOBS: ["sensor.energy_*"], - CONF_ENTITIES: [ - "sensor.apc900va_status", - "sensor.apc900va_battery_charge", - "sensor.apc900va_battery_runtime", - "sensor.apc900va_load", - ], - }, - } - extracted_filter = extract_include_exclude_filter_conf(conf) - entity_filter = convert_include_exclude_filter(extracted_filter) - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) - assert sqlalchemy_filter is not None - - for entity_id in filter_accept: - assert entity_filter(entity_id) is True - - for entity_id in filter_reject: - assert entity_filter(entity_id) is False - - ( - filtered_states_entity_ids, - filtered_events_entity_ids, - ) = await _async_get_states_and_events_with_filter( - hass, sqlalchemy_filter, filter_accept | filter_reject - ) - - assert filtered_states_entity_ids == filter_accept - assert not filtered_states_entity_ids.intersection(filter_reject) - - assert filtered_events_entity_ids == filter_accept - assert not filtered_events_entity_ids.intersection(filter_reject) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d6223eb55b3..e3a7f2a0d30 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -32,6 +32,8 @@ from .common import ( assert_states_equal_without_context, async_recorder_block_till_done, async_wait_recording_done, + db_state_attributes_to_native, + db_state_to_native, ) from tests.typing import RecorderInstanceContextManager @@ -49,7 +51,7 @@ def multiple_start_time_chunk_sizes( to call _generate_significant_states_with_session_stmt multiple times. """ with patch( - "homeassistant.components.recorder.history.modern.MAX_IDS_FOR_INDEXED_GROUP_BY", + "homeassistant.components.recorder.history.MAX_IDS_FOR_INDEXED_GROUP_BY", ids_for_start_time_chunk_sizes, ): yield @@ -884,10 +886,10 @@ async def test_get_full_significant_states_handles_empty_last_changed( db_state.entity_id = metadata_id_to_entity_id[ db_state.metadata_id ].entity_id - state = db_state.to_native() - state.attributes = db_state_attributes[ - db_state.attributes_id - ].to_native() + state = db_state_to_native(db_state) + state.attributes = db_state_attributes_to_native( + db_state_attributes[db_state.attributes_id] + ) native_states.append(state) return native_states diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py deleted file mode 100644 index 908a67cd635..00000000000 --- a/tests/components/recorder/test_history_db_schema_32.py +++ /dev/null @@ -1,737 +0,0 @@ -"""The tests the History component.""" - -from __future__ import annotations - -from collections.abc import Generator -from copy import copy -from datetime import datetime, timedelta -import json -from unittest.mock import patch, sentinel - -from freezegun import freeze_time -import pytest - -from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder, history -from homeassistant.components.recorder.filters import Filters -from homeassistant.components.recorder.models import process_timestamp -from homeassistant.components.recorder.util import session_scope -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.json import JSONEncoder -from homeassistant.util import dt as dt_util - -from .common import ( - assert_dict_of_states_equal_without_context_and_last_changed, - assert_multiple_states_equal_without_context, - assert_multiple_states_equal_without_context_and_last_changed, - assert_states_equal_without_context, - async_wait_recording_done, - old_db_schema, -) - -from tests.typing import RecorderInstanceContextManager - - -@pytest.fixture -async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceContextManager, -) -> None: - """Set up recorder.""" - - -@pytest.fixture -def disable_states_meta_manager(): - """Disable the states meta manager.""" - with patch.object( - recorder.table_managers.states_meta.StatesMetaManager, - "active", - False, - ): - yield - - -@pytest.fixture(autouse=True) -def db_schema_32(hass: HomeAssistant) -> Generator[None]: - """Fixture to initialize the db with the old schema 32.""" - with old_db_schema(hass, "32"): - yield - - -@pytest.fixture(autouse=True) -def setup_recorder( - db_schema_32, disable_states_meta_manager, recorder_mock: Recorder -) -> recorder.Recorder: - """Set up recorder.""" - - -async def test_get_full_significant_states_with_session_entity_no_matches( - hass: HomeAssistant, -) -> None: - """Test getting states at a specific point in time for entities that never have been recorded.""" - now = dt_util.utcnow() - time_before_recorder_ran = now - timedelta(days=1000) - instance = recorder.get_instance(hass) - with ( - session_scope(hass=hass) as session, - patch.object(instance.states_meta_manager, "active", False), - ): - assert ( - history.get_full_significant_states_with_session( - hass, session, time_before_recorder_ran, now, entity_ids=["demo.id"] - ) - == {} - ) - assert ( - history.get_full_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id", "demo.id2"], - ) - == {} - ) - - -async def test_significant_states_with_session_entity_minimal_response_no_matches( - hass: HomeAssistant, -) -> None: - """Test getting states at a specific point in time for entities that never have been recorded.""" - now = dt_util.utcnow() - time_before_recorder_ran = now - timedelta(days=1000) - instance = recorder.get_instance(hass) - with ( - session_scope(hass=hass) as session, - patch.object(instance.states_meta_manager, "active", False), - ): - assert ( - history.get_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id"], - minimal_response=True, - ) - == {} - ) - assert ( - history.get_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id", "demo.id2"], - minimal_response=True, - ) - == {} - ) - - -@pytest.mark.parametrize( - ("attributes", "no_attributes", "limit"), - [ - ({"attr": True}, False, 5000), - ({}, True, 5000), - ({"attr": True}, False, 3), - ({}, True, 3), - ], -) -async def test_state_changes_during_period( - hass: HomeAssistant, attributes, no_attributes, limit -) -> None: - """Test state change during period.""" - entity_id = "media_player.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state, attributes) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - - with freeze_time(start) as freezer: - set_state("idle") - set_state("YouTube") - - freezer.move_to(point) - states = [ - set_state("idle"), - set_state("Netflix"), - set_state("Plex"), - set_state("YouTube"), - ] - - freezer.move_to(end) - set_state("Netflix") - set_state("Plex") - await async_wait_recording_done(hass) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, limit=limit - ) - - assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) - - -async def test_state_changes_during_period_descending( - hass: HomeAssistant, -) -> None: - """Test state change during period descending.""" - entity_id = "media_player.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state, {"any": 1}) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - point2 = start + timedelta(seconds=1, microseconds=2) - point3 = start + timedelta(seconds=1, microseconds=3) - point4 = start + timedelta(seconds=1, microseconds=4) - end = point + timedelta(seconds=1) - - with freeze_time(start) as freezer: - set_state("idle") - set_state("YouTube") - - freezer.move_to(point) - states = [set_state("idle")] - - freezer.move_to(point2) - states.append(set_state("Netflix")) - - freezer.move_to(point3) - states.append(set_state("Plex")) - - freezer.move_to(point4) - states.append(set_state("YouTube")) - - freezer.move_to(end) - set_state("Netflix") - set_state("Plex") - await async_wait_recording_done(hass) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=False - ) - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=True - ) - assert_multiple_states_equal_without_context( - states, list(reversed(list(hist[entity_id]))) - ) - - -async def test_get_last_state_changes(hass: HomeAssistant) -> None: - """Test number of state changes.""" - entity_id = "sensor.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1, seconds=1) - states = [] - - with freeze_time(start) as freezer: - set_state("1") - - freezer.move_to(point) - states.append(set_state("2")) - - freezer.move_to(point2) - states.append(set_state("3")) - await async_wait_recording_done(hass) - - hist = history.get_last_state_changes(hass, 2, entity_id) - - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - -async def test_ensure_state_can_be_copied( - hass: HomeAssistant, -) -> None: - """Ensure a state can pass though copy(). - - The filter integration uses copy() on states - from history. - """ - entity_id = "sensor.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - - with freeze_time(start) as freezer: - set_state("1") - - freezer.move_to(point) - set_state("2") - await async_wait_recording_done(hass) - - hist = history.get_last_state_changes(hass, 2, entity_id) - - assert_states_equal_without_context( - copy(hist[entity_id][0]), hist[entity_id][0] - ) - assert_states_equal_without_context( - copy(hist[entity_id][1]), hist[entity_id][1] - ) - - -async def test_get_significant_states(hass: HomeAssistant) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_minimal_response( - hass: HomeAssistant, -) -> None: - """Test that only significant states are returned. - - When minimal responses is set only the first and - last states return a complete state. - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - hist = history.get_significant_states( - hass, zero, four, minimal_response=True, entity_ids=list(states) - ) - entites_with_reducable_states = [ - "media_player.test", - "media_player.test3", - ] - - # All states for media_player.test state are reduced - # down to last_changed and state when minimal_response - # is set except for the first state. - # is set. We use JSONEncoder to make sure that are - # pre-encoded last_changed is always the same as what - # will happen with encoding a native state - for entity_id in entites_with_reducable_states: - entity_states = states[entity_id] - for state_idx in range(1, len(entity_states)): - input_state = entity_states[state_idx] - orig_last_changed = json.dumps( - process_timestamp(input_state.last_changed), - cls=JSONEncoder, - ).replace('"', "") - orig_state = input_state.state - entity_states[state_idx] = { - "last_changed": orig_last_changed, - "state": orig_state, - } - - assert len(hist) == len(states) - assert_states_equal_without_context( - states["media_player.test"][0], hist["media_player.test"][0] - ) - assert states["media_player.test"][1] == hist["media_player.test"][1] - assert states["media_player.test"][2] == hist["media_player.test"][2] - - assert_multiple_states_equal_without_context( - states["media_player.test2"], hist["media_player.test2"] - ) - assert_states_equal_without_context( - states["media_player.test3"][0], hist["media_player.test3"][0] - ) - assert states["media_player.test3"][1] == hist["media_player.test3"][1] - - assert_multiple_states_equal_without_context( - states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test2"], hist["thermostat.test2"] - ) - - -@pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) -async def test_get_significant_states_with_initial( - time_zone, hass: HomeAssistant -) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - await hass.config.async_set_time_zone(time_zone) - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - one = zero + timedelta(seconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - if entity_id == "media_player.test": - states[entity_id] = states[entity_id][1:] - for state in states[entity_id]: - if state.last_changed == one: - state.last_changed = one_and_half - - hist = history.get_significant_states( - hass, one_and_half, four, include_start_time_state=True, entity_ids=list(states) - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_without_initial( - hass: HomeAssistant, -) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - states[entity_id] = [ - s - for s in states[entity_id] - if s.last_changed not in (one, one_with_microsecond) - ] - del states["media_player.test2"] - - hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=False, - entity_ids=list(states), - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_entity_id( - hass: HomeAssistant, -) -> None: - """Test that only significant states are returned for one entity.""" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - hist = history.get_significant_states(hass, zero, four, ["media_player.test"]) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_multiple_entity_ids( - hass: HomeAssistant, -) -> None: - """Test that only significant states are returned for one entity.""" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - hist = history.get_significant_states( - hass, - zero, - four, - ["media_player.test", "thermostat.test"], - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["media_player.test"], hist["media_player.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - - -async def test_get_significant_states_are_ordered( - hass: HomeAssistant, -) -> None: - """Test order of results from get_significant_states. - - When entity ids are given, the results should be returned with the data - in the same order. - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, _states = record_states(hass) - await async_wait_recording_done(hass) - - entity_ids = ["media_player.test", "media_player.test2"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - entity_ids = ["media_player.test2", "media_player.test"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - - -async def test_get_significant_states_only( - hass: HomeAssistant, -) -> None: - """Test significant states when significant_states_only is set.""" - entity_id = "sensor.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state, **kwargs): - """Set the state.""" - hass.states.async_set(entity_id, state, **kwargs) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=4) - points = [start + timedelta(minutes=i) for i in range(1, 4)] - - states = [] - with freeze_time(start) as freezer: - set_state("123", attributes={"attribute": 10.64}) - - freezer.move_to(points[0]) - # Attributes are different, state not - states.append(set_state("123", attributes={"attribute": 21.42})) - - freezer.move_to(points[1]) - # state is different, attributes not - states.append(set_state("32", attributes={"attribute": 21.42})) - - freezer.move_to(points[2]) - # everything is different - states.append(set_state("412", attributes={"attribute": 54.23})) - await async_wait_recording_done(hass) - - hist = history.get_significant_states( - hass, - start, - significant_changes_only=True, - entity_ids=list({state.entity_id for state in states}), - ) - - assert len(hist[entity_id]) == 2 - assert not any( - state.last_updated == states[0].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[1].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[2].last_updated for state in hist[entity_id] - ) - - hist = history.get_significant_states( - hass, - start, - significant_changes_only=False, - entity_ids=list({state.entity_id for state in states}), - ) - - assert len(hist[entity_id]) == 3 - assert_multiple_states_equal_without_context_and_last_changed( - states, hist[entity_id] - ) - - -def record_states( - hass: HomeAssistant, -) -> tuple[datetime, datetime, dict[str, list[State]]]: - """Record some test states. - - We inject a bunch of state updates from media player, zone and - thermostat. - """ - mp = "media_player.test" - mp2 = "media_player.test2" - mp3 = "media_player.test3" - therm = "thermostat.test" - therm2 = "thermostat.test2" - zone = "zone.home" - script_c = "script.can_cancel_this_one" - - def set_state(entity_id, state, **kwargs): - """Set the state.""" - hass.states.async_set(entity_id, state, **kwargs) - return hass.states.get(entity_id) - - zero = dt_util.utcnow() - one = zero + timedelta(seconds=1) - two = one + timedelta(seconds=1) - three = two + timedelta(seconds=1) - four = three + timedelta(seconds=1) - - states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with freeze_time(one) as freezer: - states[mp].append( - set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[mp2].append( - set_state(mp2, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - states[mp3].append( - set_state(mp3, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[therm].append( - set_state(therm, 20, attributes={"current_temperature": 19.5}) - ) - - freezer.move_to(one + timedelta(microseconds=1)) - states[mp].append( - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - - freezer.move_to(two) - # This state will be skipped only different in time - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) - # This state will be skipped because domain is excluded - set_state(zone, "zoning") - states[script_c].append( - set_state(script_c, "off", attributes={"can_cancel": True}) - ) - states[therm].append( - set_state(therm, 21, attributes={"current_temperature": 19.8}) - ) - states[therm2].append( - set_state(therm2, 20, attributes={"current_temperature": 19}) - ) - - freezer.move_to(three) - states[mp].append( - set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) - ) - states[mp3].append( - set_state(mp3, "Netflix", attributes={"media_title": str(sentinel.mt3)}) - ) - # Attributes changed even though state is the same - states[therm].append( - set_state(therm, 21, attributes={"current_temperature": 20}) - ) - - return zero, four, states - - -async def test_state_changes_during_period_multiple_entities_single_test( - hass: HomeAssistant, -) -> None: - """Test state change during period with multiple entities in the same test. - - This test ensures the sqlalchemy query cache does not - generate incorrect results. - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - start = dt_util.utcnow() - test_entites = {f"sensor.{i}": str(i) for i in range(30)} - for entity_id, value in test_entites.items(): - hass.states.async_set(entity_id, value) - - await async_wait_recording_done(hass) - end = dt_util.utcnow() - - for entity_id, value in test_entites.items(): - hist = history.state_changes_during_period(hass, start, end, entity_id) - assert len(hist) == 1 - assert hist[entity_id][0].state == value - - -async def test_get_significant_states_without_entity_ids_raises( - hass: HomeAssistant, -) -> None: - """Test at least one entity id is required for get_significant_states.""" - now = dt_util.utcnow() - with pytest.raises(ValueError, match="entity_ids must be provided"): - history.get_significant_states(hass, now, None) - - -async def test_state_changes_during_period_without_entity_ids_raises( - hass: HomeAssistant, -) -> None: - """Test at least one entity id is required for state_changes_during_period.""" - now = dt_util.utcnow() - with pytest.raises(ValueError, match="entity_id must be provided"): - history.state_changes_during_period(hass, now, None) - - -async def test_get_significant_states_with_filters_raises( - hass: HomeAssistant, -) -> None: - """Test passing filters is no longer supported.""" - now = dt_util.utcnow() - with pytest.raises(NotImplementedError, match="Filters are no longer supported"): - history.get_significant_states( - hass, now, None, ["media_player.test"], Filters() - ) - - -async def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test get_significant_states returns an empty dict when entities not in the db.""" - now = dt_util.utcnow() - assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} - - -async def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test state_changes_during_period returns an empty dict when entities not in the db.""" - now = dt_util.utcnow() - assert ( - history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} - ) - - -async def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test get_last_state_changes returns an empty dict when entities not in the db.""" - assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py deleted file mode 100644 index 20d0c162d35..00000000000 --- a/tests/components/recorder/test_history_db_schema_42.py +++ /dev/null @@ -1,1022 +0,0 @@ -"""The tests the History component.""" - -from __future__ import annotations - -from collections.abc import Generator -from copy import copy -from datetime import datetime, timedelta -import json -from unittest.mock import sentinel - -from freezegun import freeze_time -import pytest - -from homeassistant import core as ha -from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder, history -from homeassistant.components.recorder.filters import Filters -from homeassistant.components.recorder.models import process_timestamp -from homeassistant.components.recorder.util import session_scope -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.json import JSONEncoder -from homeassistant.util import dt as dt_util - -from .common import ( - assert_dict_of_states_equal_without_context_and_last_changed, - assert_multiple_states_equal_without_context, - assert_multiple_states_equal_without_context_and_last_changed, - assert_states_equal_without_context, - async_recorder_block_till_done, - async_wait_recording_done, - old_db_schema, -) -from .db_schema_42 import StateAttributes, States, StatesMeta - -from tests.typing import RecorderInstanceContextManager - - -@pytest.fixture -async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceContextManager, -) -> None: - """Set up recorder.""" - - -@pytest.fixture(autouse=True) -def db_schema_42(hass: HomeAssistant) -> Generator[None]: - """Fixture to initialize the db with the old schema 42.""" - with old_db_schema(hass, "42"): - yield - - -@pytest.fixture(autouse=True) -def setup_recorder(db_schema_42, recorder_mock: Recorder) -> recorder.Recorder: - """Set up recorder.""" - - -async def test_get_full_significant_states_with_session_entity_no_matches( - hass: HomeAssistant, -) -> None: - """Test getting states at a specific point in time for entities that never have been recorded.""" - now = dt_util.utcnow() - time_before_recorder_ran = now - timedelta(days=1000) - with session_scope(hass=hass, read_only=True) as session: - assert ( - history.get_full_significant_states_with_session( - hass, session, time_before_recorder_ran, now, entity_ids=["demo.id"] - ) - == {} - ) - assert ( - history.get_full_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id", "demo.id2"], - ) - == {} - ) - - -async def test_significant_states_with_session_entity_minimal_response_no_matches( - hass: HomeAssistant, -) -> None: - """Test getting states at a specific point in time for entities that never have been recorded.""" - now = dt_util.utcnow() - time_before_recorder_ran = now - timedelta(days=1000) - with session_scope(hass=hass, read_only=True) as session: - assert ( - history.get_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id"], - minimal_response=True, - ) - == {} - ) - assert ( - history.get_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id", "demo.id2"], - minimal_response=True, - ) - == {} - ) - - -async def test_significant_states_with_session_single_entity( - hass: HomeAssistant, -) -> None: - """Test get_significant_states_with_session with a single entity.""" - hass.states.async_set("demo.id", "any", {"attr": True}) - hass.states.async_set("demo.id", "any2", {"attr": True}) - await async_wait_recording_done(hass) - now = dt_util.utcnow() - with session_scope(hass=hass, read_only=True) as session: - states = history.get_significant_states_with_session( - hass, - session, - now - timedelta(days=1), - now, - entity_ids=["demo.id"], - minimal_response=False, - ) - assert len(states["demo.id"]) == 2 - - -@pytest.mark.parametrize( - ("attributes", "no_attributes", "limit"), - [ - ({"attr": True}, False, 5000), - ({}, True, 5000), - ({"attr": True}, False, 3), - ({}, True, 3), - ], -) -async def test_state_changes_during_period( - hass: HomeAssistant, attributes, no_attributes, limit -) -> None: - """Test state change during period.""" - entity_id = "media_player.test" - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state, attributes) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - - with freeze_time(start) as freezer: - set_state("idle") - set_state("YouTube") - - freezer.move_to(point) - states = [ - set_state("idle"), - set_state("Netflix"), - set_state("Plex"), - set_state("YouTube"), - ] - - freezer.move_to(end) - set_state("Netflix") - set_state("Plex") - await async_wait_recording_done(hass) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, limit=limit - ) - - assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) - - -async def test_state_changes_during_period_last_reported( - hass: HomeAssistant, -) -> None: - """Test state change during period.""" - entity_id = "media_player.test" - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return ha.State.from_dict(hass.states.get(entity_id).as_dict()) - - start = dt_util.utcnow() - point1 = start + timedelta(seconds=1) - point2 = point1 + timedelta(seconds=1) - end = point2 + timedelta(seconds=1) - - with freeze_time(start) as freezer: - set_state("idle") - - freezer.move_to(point1) - states = [set_state("YouTube")] - - freezer.move_to(point2) - set_state("YouTube") - - freezer.move_to(end) - set_state("Netflix") - await async_wait_recording_done(hass) - - hist = history.state_changes_during_period(hass, start, end, entity_id) - - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - -async def test_state_changes_during_period_descending( - hass: HomeAssistant, -) -> None: - """Test state change during period descending.""" - entity_id = "media_player.test" - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state, {"any": 1}) - return hass.states.get(entity_id) - - start = dt_util.utcnow().replace(microsecond=0) - point = start + timedelta(seconds=1) - point2 = start + timedelta(seconds=1, microseconds=100) - point3 = start + timedelta(seconds=1, microseconds=200) - point4 = start + timedelta(seconds=1, microseconds=300) - end = point + timedelta(seconds=1, microseconds=400) - - with freeze_time(start) as freezer: - set_state("idle") - set_state("YouTube") - - freezer.move_to(point) - states = [set_state("idle")] - - freezer.move_to(point2) - states.append(set_state("Netflix")) - - freezer.move_to(point3) - states.append(set_state("Plex")) - - freezer.move_to(point4) - states.append(set_state("YouTube")) - - freezer.move_to(end) - set_state("Netflix") - set_state("Plex") - await async_wait_recording_done(hass) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=False - ) - - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=True - ) - assert_multiple_states_equal_without_context( - states, list(reversed(list(hist[entity_id]))) - ) - - start_time = point2 + timedelta(microseconds=10) - hist = history.state_changes_during_period( - hass, - start_time, # Pick a point where we will generate a start time state - end, - entity_id, - no_attributes=False, - descending=True, - include_start_time_state=True, - ) - hist_states = list(hist[entity_id]) - assert hist_states[-1].last_updated == start_time - assert hist_states[-1].last_changed == start_time - assert len(hist_states) == 3 - # Make sure they are in descending order - assert ( - hist_states[0].last_updated - > hist_states[1].last_updated - > hist_states[2].last_updated - ) - assert ( - hist_states[0].last_changed - > hist_states[1].last_changed - > hist_states[2].last_changed - ) - hist = history.state_changes_during_period( - hass, - start_time, # Pick a point where we will generate a start time state - end, - entity_id, - no_attributes=False, - descending=False, - include_start_time_state=True, - ) - hist_states = list(hist[entity_id]) - assert hist_states[0].last_updated == start_time - assert hist_states[0].last_changed == start_time - assert len(hist_states) == 3 - # Make sure they are in ascending order - assert ( - hist_states[0].last_updated - < hist_states[1].last_updated - < hist_states[2].last_updated - ) - assert ( - hist_states[0].last_changed - < hist_states[1].last_changed - < hist_states[2].last_changed - ) - - -async def test_get_last_state_changes(hass: HomeAssistant) -> None: - """Test number of state changes.""" - entity_id = "sensor.test" - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1, seconds=1) - states = [] - - with freeze_time(start) as freezer: - set_state("1") - - freezer.move_to(point) - states.append(set_state("2")) - - freezer.move_to(point2) - states.append(set_state("3")) - await async_wait_recording_done(hass) - - hist = history.get_last_state_changes(hass, 2, entity_id) - - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - -async def test_get_last_state_changes_last_reported( - hass: HomeAssistant, -) -> None: - """Test number of state changes.""" - entity_id = "sensor.test" - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return ha.State.from_dict(hass.states.get(entity_id).as_dict()) - - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1, seconds=1) - states = [] - - with freeze_time(start) as freezer: - states.append(set_state("1")) - - freezer.move_to(point) - set_state("1") - - freezer.move_to(point2) - states.append(set_state("2")) - await async_wait_recording_done(hass) - - hist = history.get_last_state_changes(hass, 2, entity_id) - - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - -async def test_get_last_state_change(hass: HomeAssistant) -> None: - """Test getting the last state change for an entity.""" - entity_id = "sensor.test" - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1, seconds=1) - states = [] - - with freeze_time(start) as freezer: - set_state("1") - - freezer.move_to(point) - set_state("2") - - freezer.move_to(point2) - states.append(set_state("3")) - await async_wait_recording_done(hass) - - hist = history.get_last_state_changes(hass, 1, entity_id) - - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - -async def test_ensure_state_can_be_copied( - hass: HomeAssistant, -) -> None: - """Ensure a state can pass though copy(). - - The filter integration uses copy() on states - from history. - """ - entity_id = "sensor.test" - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - - with freeze_time(start) as freezer: - set_state("1") - - freezer.move_to(point) - set_state("2") - await async_wait_recording_done(hass) - - hist = history.get_last_state_changes(hass, 2, entity_id) - - assert_states_equal_without_context(copy(hist[entity_id][0]), hist[entity_id][0]) - assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) - - -async def test_get_significant_states(hass: HomeAssistant) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_minimal_response( - hass: HomeAssistant, -) -> None: - """Test that only significant states are returned. - - When minimal responses is set only the first and - last states return a complete state. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - hist = history.get_significant_states( - hass, zero, four, minimal_response=True, entity_ids=list(states) - ) - entites_with_reducable_states = [ - "media_player.test", - "media_player.test3", - ] - - # All states for media_player.test state are reduced - # down to last_changed and state when minimal_response - # is set except for the first state. - # is set. We use JSONEncoder to make sure that are - # pre-encoded last_changed is always the same as what - # will happen with encoding a native state - for entity_id in entites_with_reducable_states: - entity_states = states[entity_id] - for state_idx in range(1, len(entity_states)): - input_state = entity_states[state_idx] - orig_last_changed = json.dumps( - process_timestamp(input_state.last_changed), - cls=JSONEncoder, - ).replace('"', "") - orig_state = input_state.state - entity_states[state_idx] = { - "last_changed": orig_last_changed, - "state": orig_state, - } - - assert len(hist) == len(states) - assert_states_equal_without_context( - states["media_player.test"][0], hist["media_player.test"][0] - ) - assert states["media_player.test"][1] == hist["media_player.test"][1] - assert states["media_player.test"][2] == hist["media_player.test"][2] - - assert_multiple_states_equal_without_context( - states["media_player.test2"], hist["media_player.test2"] - ) - assert_states_equal_without_context( - states["media_player.test3"][0], hist["media_player.test3"][0] - ) - assert states["media_player.test3"][1] == hist["media_player.test3"][1] - - assert_multiple_states_equal_without_context( - states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test2"], hist["thermostat.test2"] - ) - - -@pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) -async def test_get_significant_states_with_initial( - time_zone, hass: HomeAssistant -) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - await hass.config.async_set_time_zone(time_zone) - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - if entity_id == "media_player.test": - states[entity_id] = states[entity_id][1:] - for state in states[entity_id]: - # If the state is recorded before the start time - # start it will have its last_updated and last_changed - # set to the start time. - if state.last_updated < one_and_half: - state.last_updated = one_and_half - state.last_changed = one_and_half - - hist = history.get_significant_states( - hass, one_and_half, four, include_start_time_state=True, entity_ids=list(states) - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_without_initial( - hass: HomeAssistant, -) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - states[entity_id] = [ - s - for s in states[entity_id] - if s.last_changed not in (one, one_with_microsecond) - ] - del states["media_player.test2"] - del states["thermostat.test3"] - - hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=False, - entity_ids=list(states), - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_entity_id( - hass: HomeAssistant, -) -> None: - """Test that only significant states are returned for one entity.""" - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["thermostat.test3"] - del states["script.can_cancel_this_one"] - - hist = history.get_significant_states(hass, zero, four, ["media_player.test"]) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_multiple_entity_ids( - hass: HomeAssistant, -) -> None: - """Test that only significant states are returned for one entity.""" - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - hist = history.get_significant_states( - hass, - zero, - four, - ["media_player.test", "thermostat.test"], - ) - - assert_multiple_states_equal_without_context_and_last_changed( - states["media_player.test"], hist["media_player.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - - -async def test_get_significant_states_are_ordered( - hass: HomeAssistant, -) -> None: - """Test order of results from get_significant_states. - - When entity ids are given, the results should be returned with the data - in the same order. - """ - zero, four, _states = record_states(hass) - await async_wait_recording_done(hass) - - entity_ids = ["media_player.test", "media_player.test2"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - entity_ids = ["media_player.test2", "media_player.test"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - - -async def test_get_significant_states_only( - hass: HomeAssistant, -) -> None: - """Test significant states when significant_states_only is set.""" - entity_id = "sensor.test" - - def set_state(state, **kwargs): - """Set the state.""" - hass.states.async_set(entity_id, state, **kwargs) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=4) - points = [start + timedelta(minutes=i) for i in range(1, 4)] - - states = [] - with freeze_time(start) as freezer: - set_state("123", attributes={"attribute": 10.64}) - - freezer.move_to(points[0]) - # Attributes are different, state not - states.append(set_state("123", attributes={"attribute": 21.42})) - - freezer.move_to(points[1]) - # state is different, attributes not - states.append(set_state("32", attributes={"attribute": 21.42})) - - freezer.move_to(points[2]) - # everything is different - states.append(set_state("412", attributes={"attribute": 54.23})) - await async_wait_recording_done(hass) - - hist = history.get_significant_states( - hass, - start, - significant_changes_only=True, - entity_ids=list({state.entity_id for state in states}), - ) - - assert len(hist[entity_id]) == 2 - assert not any( - state.last_updated == states[0].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[1].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[2].last_updated for state in hist[entity_id] - ) - - hist = history.get_significant_states( - hass, - start, - significant_changes_only=False, - entity_ids=list({state.entity_id for state in states}), - ) - - assert len(hist[entity_id]) == 3 - assert_multiple_states_equal_without_context_and_last_changed( - states, hist[entity_id] - ) - - -async def test_get_significant_states_only_minimal_response( - hass: HomeAssistant, -) -> None: - """Test significant states when significant_states_only is True.""" - now = dt_util.utcnow() - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_wait_recording_done(hass) - - hist = history.get_significant_states( - hass, - now, - minimal_response=True, - significant_changes_only=False, - entity_ids=["sensor.test"], - ) - assert len(hist["sensor.test"]) == 3 - - -def record_states( - hass: HomeAssistant, -) -> tuple[datetime, datetime, dict[str, list[State]]]: - """Record some test states. - - We inject a bunch of state updates from media player, zone and - thermostat. - """ - mp = "media_player.test" - mp2 = "media_player.test2" - mp3 = "media_player.test3" - therm = "thermostat.test" - therm2 = "thermostat.test2" - therm3 = "thermostat.test3" - zone = "zone.home" - script_c = "script.can_cancel_this_one" - - def set_state(entity_id, state, **kwargs): - """Set the state.""" - hass.states.async_set(entity_id, state, **kwargs) - return hass.states.get(entity_id) - - zero = dt_util.utcnow() - one = zero + timedelta(seconds=1) - two = one + timedelta(seconds=1) - three = two + timedelta(seconds=1) - four = three + timedelta(seconds=1) - - states = {therm: [], therm2: [], therm3: [], mp: [], mp2: [], mp3: [], script_c: []} - with freeze_time(one) as freezer: - states[mp].append( - set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[mp2].append( - set_state(mp2, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - states[mp3].append( - set_state(mp3, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[therm].append( - set_state(therm, 20, attributes={"current_temperature": 19.5}) - ) - # This state will be updated - set_state(therm3, 20, attributes={"current_temperature": 19.5}) - - freezer.move_to(one + timedelta(microseconds=1)) - states[mp].append( - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - - freezer.move_to(two) - # This state will be skipped only different in time - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) - # This state will be skipped because domain is excluded - set_state(zone, "zoning") - states[script_c].append( - set_state(script_c, "off", attributes={"can_cancel": True}) - ) - states[therm].append( - set_state(therm, 21, attributes={"current_temperature": 19.8}) - ) - states[therm2].append( - set_state(therm2, 20, attributes={"current_temperature": 19}) - ) - # This state will be updated - set_state(therm3, 20, attributes={"current_temperature": 19.5}) - - freezer.move_to(three) - states[mp].append( - set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) - ) - states[mp3].append( - set_state(mp3, "Netflix", attributes={"media_title": str(sentinel.mt3)}) - ) - # Attributes changed even though state is the same - states[therm].append( - set_state(therm, 21, attributes={"current_temperature": 20}) - ) - states[therm3].append( - set_state(therm3, 20, attributes={"current_temperature": 19.5}) - ) - - return zero, four, states - - -async def test_get_full_significant_states_handles_empty_last_changed( - hass: HomeAssistant, -) -> None: - """Test getting states when last_changed is null.""" - now = dt_util.utcnow() - hass.states.async_set("sensor.one", "on", {"attr": "original"}) - state0 = hass.states.get("sensor.one") - await hass.async_block_till_done() - hass.states.async_set("sensor.one", "on", {"attr": "new"}) - state1 = hass.states.get("sensor.one") - - assert state0.last_changed == state1.last_changed - assert state0.last_updated != state1.last_updated - await async_wait_recording_done(hass) - - def _get_entries(): - with session_scope(hass=hass, read_only=True) as session: - return history.get_full_significant_states_with_session( - hass, - session, - now, - dt_util.utcnow(), - entity_ids=["sensor.one"], - significant_changes_only=False, - ) - - states = await recorder.get_instance(hass).async_add_executor_job(_get_entries) - sensor_one_states: list[State] = states["sensor.one"] - assert_states_equal_without_context(sensor_one_states[0], state0) - assert_states_equal_without_context(sensor_one_states[1], state1) - assert sensor_one_states[0].last_changed == sensor_one_states[1].last_changed - assert sensor_one_states[0].last_updated != sensor_one_states[1].last_updated - - def _fetch_native_states() -> list[State]: - with session_scope(hass=hass, read_only=True) as session: - native_states = [] - db_state_attributes = { - state_attributes.attributes_id: state_attributes - for state_attributes in session.query(StateAttributes) - } - metadata_id_to_entity_id = { - states_meta.metadata_id: states_meta - for states_meta in session.query(StatesMeta) - } - for db_state in session.query(States): - db_state.entity_id = metadata_id_to_entity_id[ - db_state.metadata_id - ].entity_id - state = db_state.to_native() - state.attributes = db_state_attributes[ - db_state.attributes_id - ].to_native() - native_states.append(state) - return native_states - - native_sensor_one_states = await recorder.get_instance(hass).async_add_executor_job( - _fetch_native_states - ) - assert_states_equal_without_context(native_sensor_one_states[0], state0) - assert_states_equal_without_context(native_sensor_one_states[1], state1) - assert ( - native_sensor_one_states[0].last_changed - == native_sensor_one_states[1].last_changed - ) - assert ( - native_sensor_one_states[0].last_updated - != native_sensor_one_states[1].last_updated - ) - - def _fetch_db_states() -> list[States]: - with session_scope(hass=hass, read_only=True) as session: - states = list(session.query(States)) - session.expunge_all() - return states - - db_sensor_one_states = await recorder.get_instance(hass).async_add_executor_job( - _fetch_db_states - ) - assert db_sensor_one_states[0].last_changed is None - assert db_sensor_one_states[0].last_changed_ts is None - - assert ( - process_timestamp( - dt_util.utc_from_timestamp(db_sensor_one_states[1].last_changed_ts) - ) - == state0.last_changed - ) - assert db_sensor_one_states[0].last_updated_ts is not None - assert db_sensor_one_states[1].last_updated_ts is not None - assert ( - db_sensor_one_states[0].last_updated_ts - != db_sensor_one_states[1].last_updated_ts - ) - - -async def test_state_changes_during_period_multiple_entities_single_test( - hass: HomeAssistant, -) -> None: - """Test state change during period with multiple entities in the same test. - - This test ensures the sqlalchemy query cache does not - generate incorrect results. - """ - start = dt_util.utcnow() - test_entites = {f"sensor.{i}": str(i) for i in range(30)} - for entity_id, value in test_entites.items(): - hass.states.async_set(entity_id, value) - - await async_wait_recording_done(hass) - end = dt_util.utcnow() - - for entity_id, value in test_entites.items(): - hist = history.state_changes_during_period(hass, start, end, entity_id) - assert len(hist) == 1 - assert hist[entity_id][0].state == value - - -@pytest.mark.freeze_time("2039-01-19 03:14:07.555555-00:00") -async def test_get_full_significant_states_past_year_2038( - hass: HomeAssistant, -) -> None: - """Test we can store times past year 2038.""" - past_2038_time = dt_util.parse_datetime("2039-01-19 03:14:07.555555-00:00") - hass.states.async_set("sensor.one", "on", {"attr": "original"}) - state0 = hass.states.get("sensor.one") - await hass.async_block_till_done() - - hass.states.async_set("sensor.one", "on", {"attr": "new"}) - state1 = hass.states.get("sensor.one") - - await async_wait_recording_done(hass) - - def _get_entries(): - with session_scope(hass=hass, read_only=True) as session: - return history.get_full_significant_states_with_session( - hass, - session, - past_2038_time - timedelta(days=365), - past_2038_time + timedelta(days=365), - entity_ids=["sensor.one"], - significant_changes_only=False, - ) - - states = await recorder.get_instance(hass).async_add_executor_job(_get_entries) - sensor_one_states: list[State] = states["sensor.one"] - assert_states_equal_without_context(sensor_one_states[0], state0) - assert_states_equal_without_context(sensor_one_states[1], state1) - assert sensor_one_states[0].last_changed == past_2038_time - assert sensor_one_states[0].last_updated == past_2038_time - - -async def test_get_significant_states_without_entity_ids_raises( - hass: HomeAssistant, -) -> None: - """Test at least one entity id is required for get_significant_states.""" - now = dt_util.utcnow() - with pytest.raises(ValueError, match="entity_ids must be provided"): - history.get_significant_states(hass, now, None) - - -async def test_state_changes_during_period_without_entity_ids_raises( - hass: HomeAssistant, -) -> None: - """Test at least one entity id is required for state_changes_during_period.""" - now = dt_util.utcnow() - with pytest.raises(ValueError, match="entity_id must be provided"): - history.state_changes_during_period(hass, now, None) - - -async def test_get_significant_states_with_filters_raises( - hass: HomeAssistant, -) -> None: - """Test passing filters is no longer supported.""" - now = dt_util.utcnow() - with pytest.raises(NotImplementedError, match="Filters are no longer supported"): - history.get_significant_states( - hass, now, None, ["media_player.test"], Filters() - ) - - -async def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test get_significant_states returns an empty dict when entities not in the db.""" - now = dt_util.utcnow() - assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} - - -async def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test state_changes_during_period returns an empty dict when entities not in the db.""" - now = dt_util.utcnow() - assert ( - history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} - ) - - -async def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test get_last_state_changes returns an empty dict when entities not in the db.""" - assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 2023e15176f..6f6dbc7dd9c 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -90,6 +90,10 @@ from .common import ( async_wait_recording_done, convert_pending_states_to_meta, corrupt_db_file, + db_event_data_to_native, + db_event_to_native, + db_state_attributes_to_native, + db_state_to_native, run_information_with_session, ) @@ -281,8 +285,8 @@ async def test_saving_state(hass: HomeAssistant, setup_recorder: None) -> None: ): db_state.entity_id = states_meta.entity_id db_states.append(db_state) - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() + state = db_state_to_native(db_state) + state.attributes = db_state_attributes_to_native(db_state_attributes) assert len(db_states) == 1 assert db_states[0].event_id is None @@ -323,8 +327,8 @@ async def test_saving_state_with_nul( ): db_state.entity_id = states_meta.entity_id db_states.append(db_state) - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() + state = db_state_to_native(db_state) + state.attributes = db_state_attributes_to_native(db_state_attributes) assert len(db_states) == 1 assert db_states[0].event_id is None @@ -543,8 +547,8 @@ async def test_saving_event(hass: HomeAssistant, setup_recorder: None) -> None: event_data = cast(EventData, event_data) event_types = cast(EventTypes, event_types) - native_event = select_event.to_native() - native_event.data = event_data.to_native() + native_event = db_event_to_native(select_event) + native_event.data = db_event_data_to_native(event_data) native_event.event_type = event_types.event_type events.append(native_event) @@ -599,8 +603,8 @@ async def _add_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[Stat .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): db_state.entity_id = states_meta.entity_id - native_state = db_state.to_native() - native_state.attributes = db_state_attributes.to_native() + native_state = db_state_to_native(db_state) + native_state.attributes = db_state_attributes_to_native(db_state_attributes) states.append(native_state) convert_pending_states_to_meta(get_instance(hass), session) return states @@ -706,9 +710,9 @@ async def test_saving_event_exclude_event_type( event_data = cast(EventData, event_data) event_types = cast(EventTypes, event_types) - native_event = event.to_native() + native_event = db_event_to_native(event) if event_data: - native_event.data = event_data.to_native() + native_event.data = db_event_data_to_native(event_data) native_event.event_type = event_types.event_type events.append(native_event) return events @@ -878,8 +882,8 @@ async def test_saving_state_with_oversized_attributes( .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): db_state.entity_id = states_meta.entity_id - native_state = db_state.to_native() - native_state.attributes = db_state_attributes.to_native() + native_state = db_state_to_native(db_state) + native_state.attributes = db_state_attributes_to_native(db_state_attributes) states.append(native_state) assert "switch.too_big" in caplog.text @@ -1575,8 +1579,8 @@ async def test_service_disable_events_not_recording( event_data = cast(EventData, event_data) event_types = cast(EventTypes, event_types) - native_event = select_event.to_native() - native_event.data = event_data.to_native() + native_event = db_event_to_native(select_event) + native_event.data = db_event_data_to_native(event_data) native_event.event_type = event_types.event_type db_events.append(native_event) @@ -1626,7 +1630,7 @@ async def test_service_disable_states_not_recording( assert db_states[0].event_id is None db_states[0].entity_id = "test.two" assert ( - db_states[0].to_native().as_dict() + db_state_to_native(db_states[0]).as_dict() == _state_with_context(hass, "test.two").as_dict() ) @@ -1686,12 +1690,11 @@ class CannotSerializeMe: @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) -@pytest.mark.usefixtures("skip_by_db_engine") +@pytest.mark.usefixtures("recorder_mock", "skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("recorder_config", [{CONF_COMMIT_INTERVAL: 0}]) async def test_database_corruption_while_running( hass: HomeAssistant, - recorder_mock: Recorder, recorder_db_url: str, caplog: pytest.LogCaptureFixture, ) -> None: @@ -1745,7 +1748,7 @@ async def test_database_corruption_while_running( assert len(db_states) == 1 db_states[0].entity_id = "test.two" assert db_states[0].event_id is None - return db_states[0].to_native() + return db_state_to_native(db_states[0]) state = await instance.async_add_executor_job(_get_last_state) assert state.entity_id == "test.two" @@ -2428,8 +2431,8 @@ async def test_excluding_attributes_by_integration( ): db_state.entity_id = states_meta.entity_id db_states.append(db_state) - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() + state = db_state_to_native(db_state) + state.attributes = db_state_attributes_to_native(db_state_attributes) assert len(db_states) == 1 assert db_states[0].event_id is None @@ -2488,8 +2491,8 @@ async def test_excluding_all_attributes_by_integration( ): db_state.entity_id = states_meta.entity_id db_states.append(db_state) - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() + state = db_state_to_native(db_state) + state.attributes = db_state_attributes_to_native(db_state_attributes) assert len(db_states) == 1 assert db_states[0].event_id is None @@ -2692,7 +2695,7 @@ async def test_events_are_recorded_until_final_write( select_event = cast(Events, select_event) event_types = cast(EventTypes, event_types) - native_event = select_event.to_native() + native_event = db_event_to_native(select_event) native_event.event_type = event_types.event_type events.append(native_event) diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 035fd9b4440..74d319bcd97 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -32,7 +32,12 @@ from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.util import dt as dt_util -from .common import async_wait_recorder, async_wait_recording_done, create_engine_test +from .common import ( + async_wait_recorder, + async_wait_recording_done, + create_engine_test, + db_state_to_native, +) from .conftest import InstrumentedMigration from tests.common import async_fire_time_changed @@ -53,7 +58,7 @@ def _get_native_states(hass: HomeAssistant, entity_id: str) -> list[State]: states = [] for dbstate in session.query(States).filter(States.metadata_id == metadata_id): dbstate.entity_id = entity_id - states.append(dbstate.to_native()) + states.append(db_state_to_native(dbstate)) return states @@ -102,7 +107,7 @@ async def test_schema_update_calls( schema_errors=set(), start_version=0, ), - 42, + 48, ), call( instance, @@ -110,7 +115,7 @@ async def test_schema_update_calls( engine, session_maker, migration.SchemaValidationStatus( - current_version=42, + current_version=48, initial_version=0, migration_needed=True, non_live_data_migration_needed=True, @@ -228,7 +233,7 @@ async def test_database_migration_failed( # Test error handling in _modify_columns (12, "sqlalchemy.engine.base.Connection.execute", False, 1, 0), # Test error handling in _drop_foreign_key_constraints - (46, "homeassistant.components.recorder.migration.DropConstraint", False, 2, 1), + (46, "homeassistant.components.recorder.migration.DropConstraint", False, 1, 0), ], ) @pytest.mark.skip_on_db_engine(["sqlite"]) @@ -555,7 +560,8 @@ async def test_events_during_migration_queue_exhausted( (18, False), (22, False), (25, False), - (43, True), + (43, False), + (48, True), ], ) async def test_schema_migrate( diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 7fd73aaf735..6554bb57183 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -46,8 +46,10 @@ from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes from .common import ( async_attach_db_engine, + async_drop_index, async_recorder_block_till_done, async_wait_recording_done, + get_patched_live_version, ) from .conftest import instrument_migration @@ -107,6 +109,11 @@ def db_schema_32(): with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), @@ -126,6 +133,7 @@ def db_schema_32(): async def test_migrate_events_context_ids( async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], + caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" importlib.import_module(SCHEMA_MODULE_32) @@ -224,8 +232,12 @@ async def test_migrate_events_context_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EventsContextIDMigration, "migrate_data"), - patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -246,7 +258,7 @@ async def test_migrate_events_context_ids( for table, index in indices_to_drop: with session_scope(hass=hass) as session: assert get_index_by_name(session, table, index) is not None - migration._drop_index(instance.get_session, table, index) + await async_drop_index(instance, table, index, caplog) await hass.async_stop() await hass.async_block_till_done() @@ -283,13 +295,21 @@ async def test_migrate_events_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, - patch.object(migration.EventIDPostMigration, "migrate_data"), ): + # Stall migration when the last non-live schema migration is done + instrumented_migration.stall_on_schema_version = ( + migration.LIVE_MIGRATION_MIN_SCHEMA_VERSION + ) async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False ) as instance: # Check the context ID migrator is considered non-live assert recorder.util.async_migration_is_live(hass) is False + # Wait for non-live schema migration to complete + await hass.async_add_executor_job( + instrumented_migration.apply_update_stalled.wait + ) + wrapped_idx_create.reset_mock() instrumented_migration.migration_stall.set() instance.recorder_and_worker_thread_ids.add(threading.get_ident()) @@ -422,14 +442,12 @@ async def test_finish_migrate_events_context_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), - patch.object(migration.EventsContextIDMigration, "migrate_data"), patch.object( - migration.EventIDPostMigration, - "needs_migrate_impl", - return_value=migration.DataMigrationStatus( - needs_migrate=False, migration_done=True - ), + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), ), + patch.object(migration.EventsContextIDMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -509,6 +527,7 @@ async def test_finish_migrate_events_context_ids( async def test_migrate_states_context_ids( async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], + caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" importlib.import_module(SCHEMA_MODULE_32) @@ -589,8 +608,12 @@ async def test_migrate_states_context_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.StatesContextIDMigration, "migrate_data"), - patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -607,7 +630,7 @@ async def test_migrate_states_context_ids( for table, index in indices_to_drop: with session_scope(hass=hass) as session: assert get_index_by_name(session, table, index) is not None - migration._drop_index(instance.get_session, table, index) + await async_drop_index(instance, table, index, caplog) await hass.async_stop() await hass.async_block_till_done() @@ -643,13 +666,21 @@ async def test_migrate_states_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, - patch.object(migration.EventIDPostMigration, "migrate_data"), ): + # Stall migration when the last non-live schema migration is done + instrumented_migration.stall_on_schema_version = ( + migration.LIVE_MIGRATION_MIN_SCHEMA_VERSION + ) async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False ) as instance: # Check the context ID migrator is considered non-live assert recorder.util.async_migration_is_live(hass) is False + # Wait for non-live schema migration to complete + await hass.async_add_executor_job( + instrumented_migration.apply_update_stalled.wait + ) + wrapped_idx_create.reset_mock() instrumented_migration.migration_stall.set() instance.recorder_and_worker_thread_ids.add(threading.get_ident()) @@ -786,14 +817,12 @@ async def test_finish_migrate_states_context_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), - patch.object(migration.StatesContextIDMigration, "migrate_data"), patch.object( - migration.EventIDPostMigration, - "needs_migrate_impl", - return_value=migration.DataMigrationStatus( - needs_migrate=False, migration_done=True - ), + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), ), + patch.object(migration.StatesContextIDMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -902,6 +931,11 @@ async def test_migrate_event_type_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EventTypeIDMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): @@ -1020,6 +1054,11 @@ async def test_migrate_entity_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EntityIDMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): @@ -1098,6 +1137,7 @@ async def test_migrate_entity_ids( async def test_post_migrate_entity_ids( async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], + caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" importlib.import_module(SCHEMA_MODULE_32) @@ -1129,9 +1169,13 @@ async def test_post_migrate_entity_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EntityIDMigration, "migrate_data"), patch.object(migration.EntityIDPostMigration, "migrate_data"), - patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -1148,7 +1192,7 @@ async def test_post_migrate_entity_ids( for table, index in indices_to_drop: with session_scope(hass=hass) as session: assert get_index_by_name(session, table, index) is not None - migration._drop_index(instance.get_session, table, index) + await async_drop_index(instance, table, index, caplog) await hass.async_stop() await hass.async_block_till_done() @@ -1163,36 +1207,48 @@ async def test_post_migrate_entity_ids( return {state.state: state.entity_id for state in states} # Run again with new schema, let migration run - with ( - patch( - "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create - ) as wrapped_idx_create, - patch.object(migration.EventIDPostMigration, "migrate_data"), - ): - async with ( - async_test_home_assistant() as hass, - async_test_recorder(hass) as instance, + async with async_test_home_assistant() as hass: + with ( + instrument_migration(hass) as instrumented_migration, + patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create, ): - instance.recorder_and_worker_thread_ids.add(threading.get_ident()) - - await hass.async_block_till_done() - await async_wait_recording_done(hass) - - states_by_state = await instance.async_add_executor_job( - _fetch_migrated_states + # Stall migration when the last non-live schema migration is done + instrumented_migration.stall_on_schema_version = ( + migration.LIVE_MIGRATION_MIN_SCHEMA_VERSION ) + async with async_test_recorder( + hass, wait_recorder=False, wait_recorder_setup=False + ) as instance: + # Wait for non-live schema migration to complete + await hass.async_add_executor_job( + instrumented_migration.apply_update_stalled.wait + ) + wrapped_idx_create.reset_mock() + instrumented_migration.migration_stall.set() - # Check the index which will be removed by the migrator no longer exists - with session_scope(hass=hass) as session: - assert ( - get_index_by_name( - session, "states", "ix_states_entity_id_last_updated_ts" - ) - is None + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_state = await instance.async_add_executor_job( + _fetch_migrated_states ) - await hass.async_stop() - await hass.async_block_till_done() + # Check the index which will be removed by the migrator no longer exists + with session_scope(hass=hass) as session: + assert ( + get_index_by_name( + session, "states", "ix_states_entity_id_last_updated_ts" + ) + is None + ) + + await hass.async_stop() + await hass.async_block_till_done() # Check the index we removed was recreated index_names = [call[1][0].name for call in wrapped_idx_create.mock_calls] @@ -1242,6 +1298,11 @@ async def test_migrate_null_entity_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EntityIDMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): @@ -1352,6 +1413,11 @@ async def test_migrate_null_event_type_ids( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EventTypeIDMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): @@ -2048,6 +2114,11 @@ async def test_stats_migrate_times( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): @@ -2243,6 +2314,11 @@ async def test_cleanup_unmigrated_state_timestamps( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -2252,7 +2328,6 @@ async def test_cleanup_unmigrated_state_timestamps( await instance.async_add_executor_job(_insert_states) await async_wait_recording_done(hass) - now = dt_util.utcnow() await _async_wait_migration_done(hass) await async_wait_recording_done(hass) @@ -2266,29 +2341,22 @@ async def test_cleanup_unmigrated_state_timestamps( return {state.state_id: _object_as_dict(state) for state in states} # Run again with new schema, let migration run - async with async_test_home_assistant() as hass: - with ( - freeze_time(now), - instrument_migration(hass) as instrumented_migration, - ): - async with async_test_recorder( - hass, wait_recorder=False, wait_recorder_setup=False - ) as instance: - # Check the context ID migrator is considered non-live - assert recorder.util.async_migration_is_live(hass) is False - instrumented_migration.migration_stall.set() - instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) - await hass.async_block_till_done() - await async_wait_recording_done(hass) - await async_wait_recording_done(hass) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - states_by_metadata_id = await instance.async_add_executor_job( - _fetch_migrated_states - ) + states_by_metadata_id = await instance.async_add_executor_job( + _fetch_migrated_states + ) - await hass.async_stop() - await hass.async_block_till_done() + await hass.async_stop() + await hass.async_block_till_done() assert len(states_by_metadata_id) == 3 for state in states_by_metadata_id.values(): diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index 350126b4c72..69b6c6ee42b 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -22,7 +22,11 @@ from homeassistant.components.recorder.util import ( from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from .common import async_recorder_block_till_done, async_wait_recording_done +from .common import ( + async_recorder_block_till_done, + async_wait_recording_done, + get_patched_live_version, +) from tests.common import async_test_home_assistant from tests.typing import RecorderInstanceContextManager @@ -329,6 +333,11 @@ async def test_migration_changes_prevent_trying_to_migrate_again( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 689441260c7..24c79b73a4e 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -21,7 +21,13 @@ from homeassistant.components.recorder.models import ( from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.util import dt as dt_util -from homeassistant.util.json import json_loads +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads + +from .common import ( + db_event_to_native, + db_state_attributes_to_native, + db_state_to_native, +) def test_from_event_to_db_event() -> None: @@ -39,7 +45,7 @@ def test_from_event_to_db_event() -> None: dialect = SupportedDialect.MYSQL db_event.event_data = EventData.shared_data_bytes_from_event(event, dialect) db_event.event_type = event.event_type - assert event.as_dict() == db_event.to_native().as_dict() + assert event.as_dict() == db_event_to_native(db_event).as_dict() def test_from_event_to_db_event_with_null() -> None: @@ -70,7 +76,10 @@ def test_from_event_to_db_state() -> None: {"entity_id": "sensor.temperature", "old_state": None, "new_state": state}, context=state.context, ) - assert state.as_dict() == States.from_event(event).to_native().as_dict() + db_state = States.from_event(event) + # Set entity_id, it's set to None by States.from_event + db_state.entity_id = state.entity_id + assert state.as_dict() == db_state_to_native(db_state).as_dict() def test_from_event_to_db_state_attributes() -> None: @@ -88,7 +97,7 @@ def test_from_event_to_db_state_attributes() -> None: db_attrs.shared_attrs = StateAttributes.shared_attrs_bytes_from_event( event, dialect ) - assert db_attrs.to_native() == attrs + assert db_state_attributes_to_native(db_attrs) == attrs def test_from_event_to_db_state_attributes_with_null() -> None: @@ -161,15 +170,13 @@ def test_events_repr_without_timestamp() -> None: assert "2016-07-09 11:00:00+00:00" in repr(events) -def test_handling_broken_json_state_attributes( - caplog: pytest.LogCaptureFixture, -) -> None: +def test_handling_broken_json_state_attributes() -> None: """Test we handle broken json in state attributes.""" state_attributes = StateAttributes( attributes_id=444, hash=1234, shared_attrs="{NOT_PARSE}" ) - assert state_attributes.to_native() == {} - assert "Error converting row to state attributes" in caplog.text + with pytest.raises(JSON_DECODE_EXCEPTIONS): + db_state_attributes_to_native(state_attributes) def test_from_event_to_delete_state() -> None: @@ -184,7 +191,7 @@ def test_from_event_to_delete_state() -> None: ) db_state = States.from_event(event) - assert db_state.entity_id == "sensor.temperature" + assert db_state.entity_id is None assert db_state.state == "" assert db_state.last_changed_ts is None assert db_state.last_updated_ts == pytest.approx(event.time_fired.timestamp()) @@ -196,9 +203,9 @@ def test_states_from_native_invalid_entity_id() -> None: state.entity_id = "test.invalid__id" state.attributes = "{}" with pytest.raises(InvalidEntityFormatError): - state = state.to_native() + state = db_state_to_native(state) - state = state.to_native(validate_entity_id=False) + state = db_state_to_native(state, validate_entity_id=False) assert state.entity_id == "test.invalid__id" @@ -279,10 +286,10 @@ async def test_event_to_db_model() -> None: dialect = SupportedDialect.MYSQL db_event.event_data = EventData.shared_data_bytes_from_event(event, dialect) db_event.event_type = event.event_type - native = db_event.to_native() + native = db_event_to_native(db_event) assert native.as_dict() == event.as_dict() - native = Events.from_event(event).to_native() + native = db_event_to_native(Events.from_event(event)) native.data = ( event.data ) # data is not set by from_event as its in the event_data table diff --git a/tests/components/recorder/test_models_legacy.py b/tests/components/recorder/test_models_legacy.py deleted file mode 100644 index f4cdcd7268b..00000000000 --- a/tests/components/recorder/test_models_legacy.py +++ /dev/null @@ -1,99 +0,0 @@ -"""The tests for the Recorder component legacy models.""" - -from datetime import datetime, timedelta -from unittest.mock import PropertyMock - -import pytest - -from homeassistant.components.recorder.models.legacy import LegacyLazyState -from homeassistant.util import dt as dt_util - - -async def test_legacy_lazy_state_prefers_shared_attrs_over_attrs( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that the LazyState prefers shared_attrs over attributes.""" - row = PropertyMock( - entity_id="sensor.invalid", - shared_attrs='{"shared":true}', - attributes='{"shared":false}', - ) - assert LegacyLazyState(row, {}, None).attributes == {"shared": True} - - -async def test_legacy_lazy_state_handles_different_last_updated_and_last_changed( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that the LazyState handles different last_updated and last_changed.""" - now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - row = PropertyMock( - entity_id="sensor.valid", - state="off", - shared_attrs='{"shared":true}', - last_updated_ts=now.timestamp(), - last_changed_ts=(now - timedelta(seconds=60)).timestamp(), - ) - lstate = LegacyLazyState(row, {}, None) - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2021-06-12T03:03:01.000323+00:00", - "last_updated": "2021-06-12T03:04:01.000323+00:00", - "state": "off", - } - assert lstate.last_updated.timestamp() == row.last_updated_ts - assert lstate.last_changed.timestamp() == row.last_changed_ts - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2021-06-12T03:03:01.000323+00:00", - "last_updated": "2021-06-12T03:04:01.000323+00:00", - "state": "off", - } - - -async def test_legacy_lazy_state_handles_same_last_updated_and_last_changed( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that the LazyState handles same last_updated and last_changed.""" - now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - row = PropertyMock( - entity_id="sensor.valid", - state="off", - shared_attrs='{"shared":true}', - last_updated_ts=now.timestamp(), - last_changed_ts=now.timestamp(), - ) - lstate = LegacyLazyState(row, {}, None) - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2021-06-12T03:04:01.000323+00:00", - "last_updated": "2021-06-12T03:04:01.000323+00:00", - "state": "off", - } - assert lstate.last_updated.timestamp() == row.last_updated_ts - assert lstate.last_changed.timestamp() == row.last_changed_ts - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2021-06-12T03:04:01.000323+00:00", - "last_updated": "2021-06-12T03:04:01.000323+00:00", - "state": "off", - } - lstate.last_updated = datetime(2020, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2021-06-12T03:04:01.000323+00:00", - "last_updated": "2020-06-12T03:04:01.000323+00:00", - "state": "off", - } - lstate.last_changed = datetime(2020, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2020-06-12T03:04:01.000323+00:00", - "last_updated": "2020-06-12T03:04:01.000323+00:00", - "state": "off", - } diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 2bfc2887ab2..38da6ad6c81 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -2054,8 +2054,6 @@ async def test_purge_old_states_purges_the_state_metadata_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: """Test deleting old states purges state metadata_ids.""" - assert recorder_mock.states_meta_manager.active is True - utcnow = dt_util.utcnow() five_days_ago = utcnow - timedelta(days=5) eleven_days_ago = utcnow - timedelta(days=11) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index a8d8ed61020..d29ee04a469 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -61,6 +61,7 @@ from .common import ( from tests.common import MockPlatform, MockUser, mock_platform from tests.typing import RecorderInstanceContextManager, WebSocketGenerator +from tests.util.test_unit_conversion import _ALL_CONVERTERS @pytest.fixture @@ -846,8 +847,8 @@ async def test_statistics_duplicated( ("recorder", "sensor.total_energy_import", async_import_statistics), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_import_statistics( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, @@ -3740,3 +3741,24 @@ async def test_get_statistics_service_missing_mandatory_keys( return_response=True, blocking=True, ) + + +# The STATISTIC_UNIT_TO_UNIT_CONVERTER keys are sorted to ensure that pytest runs are +# consistent and avoid `different tests were collected between gw0 and gw1` +@pytest.mark.parametrize( + "uom", sorted(STATISTIC_UNIT_TO_UNIT_CONVERTER, key=lambda x: (x is None, x)) +) +def test_STATISTIC_UNIT_TO_UNIT_CONVERTER(uom: str) -> None: + """Ensure unit does not belong to multiple converters.""" + unit_converter = STATISTIC_UNIT_TO_UNIT_CONVERTER[uom] + if other := next( + ( + c + for c in _ALL_CONVERTERS + if unit_converter is not c and uom in c.VALID_UNITS + ), + None, + ): + pytest.fail( + f"{uom} is present in both {other.__name__} and {unit_converter.__name__}" + ) diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index 49b8836af70..aac26c2da66 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -23,6 +23,7 @@ from .common import ( CREATE_ENGINE_TARGET, async_wait_recording_done, create_engine_test_for_schema_version_postfix, + get_patched_live_version, get_schema_module_path, ) @@ -169,6 +170,11 @@ async def test_delete_duplicates( patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), + patch.object( + recorder.migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object( recorder.migration, "non_live_data_migration_needed", return_value=False ), @@ -357,6 +363,11 @@ async def test_delete_duplicates_many( patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), + patch.object( + recorder.migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object( recorder.migration, "non_live_data_migration_needed", return_value=False ), @@ -523,6 +534,11 @@ async def test_delete_duplicates_non_identical( patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), + patch.object( + recorder.migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object( recorder.migration, "non_live_data_migration_needed", return_value=False ), @@ -649,6 +665,11 @@ async def test_delete_duplicates_short_term( patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), + patch.object( + recorder.migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object( recorder.migration, "non_live_data_migration_needed", return_value=False ), diff --git a/tests/components/recorder/test_system_health.py b/tests/components/recorder/test_system_health.py index 0efaa82e5e5..845b95df256 100644 --- a/tests/components/recorder/test_system_health.py +++ b/tests/components/recorder/test_system_health.py @@ -4,7 +4,7 @@ from unittest.mock import ANY, Mock, patch import pytest -from homeassistant.components.recorder import Recorder, get_instance +from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.const import SupportedDialect from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -16,9 +16,9 @@ from tests.typing import RecorderInstanceGenerator @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) -@pytest.mark.usefixtures("skip_by_db_engine") +@pytest.mark.usefixtures("skip_by_db_engine", "recorder_mock") async def test_recorder_system_health( - recorder_mock: Recorder, hass: HomeAssistant, recorder_db_url: str + hass: HomeAssistant, recorder_db_url: str ) -> None: """Test recorder system health. @@ -41,8 +41,8 @@ async def test_recorder_system_health( @pytest.mark.parametrize( "db_engine", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] ) +@pytest.mark.usefixtures("recorder_mock") async def test_recorder_system_health_alternate_dbms( - recorder_mock: Recorder, hass: HomeAssistant, db_engine: SupportedDialect, recorder_dialect_name: None, @@ -70,8 +70,8 @@ async def test_recorder_system_health_alternate_dbms( @pytest.mark.parametrize( "db_engine", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] ) +@pytest.mark.usefixtures("recorder_mock") async def test_recorder_system_health_db_url_missing_host( - recorder_mock: Recorder, hass: HomeAssistant, db_engine: SupportedDialect, recorder_dialect_name: None, diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 6c324f4b01a..7f6ec871fa5 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -25,9 +25,7 @@ from homeassistant.components.recorder.const import ( SupportedDialect, ) from homeassistant.components.recorder.db_schema import RecorderRuns -from homeassistant.components.recorder.history.modern import ( - _get_single_entity_start_time_stmt, -) +from homeassistant.components.recorder.history import _get_single_entity_start_time_stmt from homeassistant.components.recorder.models import ( UnsupportedDialect, process_timestamp, @@ -86,18 +84,18 @@ async def test_session_scope_not_setup( async def test_recorder_bad_execute(hass: HomeAssistant, setup_recorder: None) -> None: """Bad execute, retry 3 times.""" - def to_native(validate_entity_id=True): + def _all(): """Raise exception.""" raise SQLAlchemyError mck1 = MagicMock() - mck1.to_native = to_native + mck1.all = _all with ( pytest.raises(SQLAlchemyError), patch("homeassistant.components.recorder.core.time.sleep") as e_mock, ): - util.execute((mck1,), to_native=True) + util.execute(mck1) assert e_mock.call_count == 2 diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index c4c1285990d..dd707ad6056 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -19,7 +19,11 @@ from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Event, EventOrigin, State from homeassistant.util import dt as dt_util -from .common import async_wait_recording_done +from .common import ( + async_drop_index, + async_wait_recording_done, + get_patched_live_version, +) from .conftest import instrument_migration from tests.common import async_test_home_assistant @@ -70,6 +74,7 @@ def _create_engine_test( @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +@pytest.mark.parametrize("enable_migrate_event_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_times( @@ -119,6 +124,11 @@ async def test_migrate_times( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch.object(migration, "post_migrate_entity_ids", return_value=False), patch.object(migration.EventsContextIDMigration, "migrate_data"), @@ -237,6 +247,7 @@ async def test_migrate_times( @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +@pytest.mark.parametrize("enable_migrate_event_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_can_resume_entity_id_post_migration( @@ -281,6 +292,11 @@ async def test_migrate_can_resume_entity_id_post_migration( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch.object(migration, "post_migrate_entity_ids", return_value=False), @@ -407,6 +423,11 @@ async def test_migrate_can_resume_ix_states_event_id_removed( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch.object(migration, "post_migrate_entity_ids", return_value=False), @@ -415,6 +436,7 @@ async def test_migrate_can_resume_ix_states_event_id_removed( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), + patch.object(migration, "Base", old_db_schema.Base), patch( CREATE_ENGINE_TARGET, new=_create_engine_test( @@ -440,12 +462,22 @@ async def test_migrate_can_resume_ix_states_event_id_removed( await hass.async_block_till_done() await instance.async_block_till_done() - await instance.async_add_executor_job( - migration._drop_index, - instance.get_session, - "states", - "ix_states_event_id", - ) + if not recorder_db_url.startswith("sqlite://"): + await instance.async_add_executor_job( + migration._drop_foreign_key_constraints, + instance.get_session, + instance.engine, + "states", + "event_id", + ) + await async_drop_index(instance, "states", "ix_states_event_id", caplog) + if not recorder_db_url.startswith("sqlite://"): + await instance.async_add_executor_job( + migration._restore_foreign_key_constraints, + instance.get_session, + instance.engine, + [("states", "event_id", "events", "event_id")], + ) states_indexes = await instance.async_add_executor_job( _get_states_index_names @@ -546,6 +578,11 @@ async def test_out_of_disk_space_while_rebuild_states_table( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch.object(migration, "post_migrate_entity_ids", return_value=False), @@ -579,12 +616,7 @@ async def test_out_of_disk_space_while_rebuild_states_table( await hass.async_block_till_done() await instance.async_block_till_done() - await instance.async_add_executor_job( - migration._drop_index, - instance.get_session, - "states", - "ix_states_event_id", - ) + await async_drop_index(instance, "states", "ix_states_event_id", caplog) states_indexes = await instance.async_add_executor_job( _get_states_index_names @@ -730,6 +762,11 @@ async def test_out_of_disk_space_while_removing_foreign_key( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object( + migration, + "LIVE_MIGRATION_MIN_SCHEMA_VERSION", + get_patched_live_version(old_db_schema), + ), patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(migration, "non_live_data_migration_needed", return_value=False), patch.object(migration, "post_migrate_entity_ids", return_value=False), @@ -738,6 +775,7 @@ async def test_out_of_disk_space_while_removing_foreign_key( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), + patch.object(migration, "Base", old_db_schema.Base), patch( CREATE_ENGINE_TARGET, new=_create_engine_test( @@ -764,10 +802,18 @@ async def test_out_of_disk_space_while_removing_foreign_key( await instance.async_block_till_done() await instance.async_add_executor_job( - migration._drop_index, + migration._drop_foreign_key_constraints, instance.get_session, + instance.engine, "states", - "ix_states_event_id", + "event_id", + ) + await async_drop_index(instance, "states", "ix_states_event_id", caplog) + await instance.async_add_executor_job( + migration._restore_foreign_key_constraints, + instance.get_session, + instance.engine, + [("states", "event_id", "events", "event_id")], ) states_indexes = await instance.async_add_executor_job( @@ -799,11 +845,28 @@ async def test_out_of_disk_space_while_removing_foreign_key( instrumented_migration.live_migration_done.wait ) + # The states.event_id foreign key constraint was removed when + # migration to schema version 46 + assert ( + await instance.async_add_executor_job(_get_event_id_foreign_keys) + is None + ) + + # Re-add the foreign key constraint to simulate failure to remove it during + # schema migration + with patch.object(migration, "Base", old_db_schema.Base): + await instance.async_add_executor_job( + migration._restore_foreign_key_constraints, + instance.get_session, + instance.engine, + [("states", "event_id", "events", "event_id")], + ) + # Simulate out of disk space while removing the foreign key from the states table by # - patching DropConstraint to raise InternalError for MySQL and PostgreSQL with ( patch( - "homeassistant.components.recorder.migration.sqlalchemy.inspect", + "homeassistant.components.recorder.migration.DropConstraint.__init__", side_effect=OperationalError( None, None, OSError("No space left on device") ), @@ -821,14 +884,6 @@ async def test_out_of_disk_space_while_removing_foreign_key( ) states_index_names = {index["name"] for index in states_indexes} assert instance.use_legacy_events_index is True - # The states.event_id foreign key constraint was removed when - # migration to schema version 46 - assert ( - await instance.async_add_executor_job( - _get_event_id_foreign_keys - ) - is None - ) await hass.async_stop() diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 2460de994ec..aa302548517 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -178,8 +178,9 @@ def test_converters_align_with_sensor() -> None: assert any(c for c in UNIT_CONVERTERS.values() if unit_class == c.UNIT_CLASS) +@pytest.mark.usefixtures("recorder_mock") async def test_statistics_during_period( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test statistics_during_period.""" now = get_start_time(dt_util.utcnow()) @@ -1067,8 +1068,9 @@ async def test_statistic_during_period_circular_mean( @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") async def test_statistic_during_period_hole( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test statistic_during_period when there are holes in the data.""" now = dt_util.utcnow() @@ -1377,8 +1379,8 @@ async def test_statistic_during_period_hole_circular_mean( datetime.datetime(2022, 10, 21, 7, 31, tzinfo=datetime.UTC), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_statistic_during_period_partial_overlap( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, @@ -1742,6 +1744,16 @@ async def test_statistic_during_period_partial_overlap( "2022-10-10T07:00:00+00:00", "2022-10-17T07:00:00+00:00", ), + ( + {"period": "week", "first_weekday": "sat"}, + "2022-10-15T07:00:00+00:00", + "2022-10-22T07:00:00+00:00", + ), + ( + {"period": "week", "first_weekday": "fri"}, + "2022-10-21T07:00:00+00:00", + "2022-10-28T07:00:00+00:00", + ), ( {"period": "month"}, "2022-10-01T07:00:00+00:00", @@ -1764,8 +1776,8 @@ async def test_statistic_during_period_partial_overlap( ), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_statistic_during_period_calendar( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calendar_period, @@ -1820,8 +1832,8 @@ async def test_statistic_during_period_calendar( (VOLUME_SENSOR_M3_ATTRIBUTES, 10, 10, {"volume": "ft³"}, 353.14666), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_statistics_during_period_unit_conversion( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, attributes, @@ -1907,8 +1919,8 @@ async def test_statistics_during_period_unit_conversion( (VOLUME_SENSOR_M3_ATTRIBUTES_TOTAL, 10, 10, {"volume": "ft³"}, 353.147), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_sum_statistics_during_period_unit_conversion( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, attributes, @@ -1997,8 +2009,8 @@ async def test_sum_statistics_during_period_unit_conversion( {"volume": "kWh"}, ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_statistics_during_period_invalid_unit_conversion( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, custom_units, @@ -2039,8 +2051,9 @@ async def test_statistics_during_period_invalid_unit_conversion( assert response["error"]["code"] == "invalid_format" +@pytest.mark.usefixtures("recorder_mock") async def test_statistics_during_period_in_the_past( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test statistics_during_period in the past.""" await hass.config.async_set_time_zone("UTC") @@ -2151,8 +2164,9 @@ async def test_statistics_during_period_in_the_past( assert response["result"] == {} +@pytest.mark.usefixtures("recorder_mock") async def test_statistics_during_period_bad_start_time( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass_ws_client: WebSocketGenerator, ) -> None: """Test statistics_during_period.""" client = await hass_ws_client() @@ -2169,8 +2183,9 @@ async def test_statistics_during_period_bad_start_time( assert response["error"]["code"] == "invalid_start_time" +@pytest.mark.usefixtures("recorder_mock") async def test_statistics_during_period_bad_end_time( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass_ws_client: WebSocketGenerator, ) -> None: """Test statistics_during_period.""" now = dt_util.utcnow() @@ -2190,8 +2205,9 @@ async def test_statistics_during_period_bad_end_time( assert response["error"]["code"] == "invalid_end_time" +@pytest.mark.usefixtures("recorder_mock") async def test_statistics_during_period_no_statistic_ids( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass_ws_client: WebSocketGenerator, ) -> None: """Test statistics_during_period without passing statistic_ids.""" now = dt_util.utcnow() @@ -2210,8 +2226,9 @@ async def test_statistics_during_period_no_statistic_ids( assert response["error"]["code"] == "invalid_format" +@pytest.mark.usefixtures("recorder_mock") async def test_statistics_during_period_empty_statistic_ids( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass_ws_client: WebSocketGenerator, ) -> None: """Test statistics_during_period with passing an empty list of statistic_ids.""" now = dt_util.utcnow() @@ -2290,8 +2307,8 @@ async def test_statistics_during_period_empty_statistic_ids( (METRIC_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES_TOTAL, "ft³", "ft³", "volume"), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_list_statistic_ids( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, units, @@ -2468,8 +2485,8 @@ async def test_list_statistic_ids( ), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_list_statistic_ids_unit_change( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, attributes, @@ -2541,9 +2558,8 @@ async def test_list_statistic_ids_unit_change( ] -async def test_validate_statistics( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_validate_statistics(hass_ws_client: WebSocketGenerator) -> None: """Test validate_statistics can be called.""" async def assert_validation_result(client, expected_result): @@ -2557,9 +2573,8 @@ async def test_validate_statistics( await assert_validation_result(client, {}) -async def test_update_statistics_issues( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_update_statistics_issues(hass_ws_client: WebSocketGenerator) -> None: """Test update_statistics_issues can be called.""" client = await hass_ws_client() @@ -2569,8 +2584,9 @@ async def test_update_statistics_issues( assert response["result"] is None +@pytest.mark.usefixtures("recorder_mock") async def test_clear_statistics( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test removing statistics.""" now = get_start_time(dt_util.utcnow()) @@ -2689,9 +2705,8 @@ async def test_clear_statistics( assert response["result"] == {"sensor.test2": expected_response["sensor.test2"]} -async def test_clear_statistics_time_out( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: +@pytest.mark.usefixtures("recorder_mock") +async def test_clear_statistics_time_out(hass_ws_client: WebSocketGenerator) -> None: """Test removing statistics with time-out error.""" client = await hass_ws_client() @@ -2717,8 +2732,8 @@ async def test_clear_statistics_time_out( ("new_unit", "new_unit_class", "new_display_unit"), [("dogs", None, "dogs"), (None, "unitless", None), ("W", "power", "kW")], ) +@pytest.mark.usefixtures("recorder_mock") async def test_update_statistics_metadata( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, new_unit, @@ -2815,8 +2830,9 @@ async def test_update_statistics_metadata( } +@pytest.mark.usefixtures("recorder_mock") async def test_update_statistics_metadata_time_out( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass_ws_client: WebSocketGenerator, ) -> None: """Test update statistics metadata with time-out error.""" client = await hass_ws_client() @@ -2840,8 +2856,9 @@ async def test_update_statistics_metadata_time_out( } +@pytest.mark.usefixtures("recorder_mock") async def test_change_statistics_unit( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test change unit of recorded statistics.""" now = get_start_time(dt_util.utcnow()) @@ -2987,8 +3004,8 @@ async def test_change_statistics_unit( ] +@pytest.mark.usefixtures("recorder_mock") async def test_change_statistics_unit_errors( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, @@ -3099,8 +3116,9 @@ async def test_change_statistics_unit_errors( await assert_statistics(expected_statistics) +@pytest.mark.usefixtures("recorder_mock") async def test_recorder_info( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test getting recorder status.""" client = await hass_ws_client() @@ -3313,8 +3331,8 @@ async def test_backup_start_no_recorder( (METRIC_SYSTEM, VOLUME_SENSOR_M3_ATTRIBUTES, "m³", "volume"), ], ) +@pytest.mark.usefixtures("recorder_mock") async def test_get_statistics_metadata( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, units, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index f8134a515e0..82d96d32622 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -145,6 +145,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: # enums host_mock.whiteled_mode.return_value = 1 host_mock.whiteled_mode_list.return_value = ["off", "auto"] + host_mock.whiteled_color_temperature.return_value = 3000 host_mock.doorbell_led.return_value = "Off" host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] host_mock.auto_track_method.return_value = 3 @@ -166,9 +167,11 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.get_privacy_mode = AsyncMock() host_mock.baichuan.set_privacy_mode = AsyncMock() host_mock.baichuan.set_scene = AsyncMock() + host_mock.baichuan.set_floodlight = AsyncMock() host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" + host_mock.baichuan.siren_state.return_value = True host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") host_mock.baichuan.active_scene = "off" host_mock.baichuan.scene_names = ["off", "home"] @@ -182,6 +185,17 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.smart_ai_index.return_value = 1 host_mock.baichuan.smart_ai_name.return_value = "zone1" + def ai_detect_type(channel: int, object_type: str) -> str | None: + if object_type == "people": + return "man" + if object_type == "dog_cat": + return "dog" + if object_type == "vehicle": + return "motorcycle" + return None + + host_mock.baichuan.ai_detect_type = ai_detect_type + @pytest.fixture def reolink_host_class() -> Generator[MagicMock]: @@ -250,6 +264,7 @@ def reolink_chime(reolink_host: MagicMock) -> None: } TEST_CHIME.remove = AsyncMock() TEST_CHIME.set_option = AsyncMock() + TEST_CHIME.update_enums() reolink_host.chime_list = [TEST_CHIME] reolink_host.chime.return_value = TEST_CHIME diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index ca35d7eb70f..a7471475e54 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -81,13 +81,17 @@ '0': 1, 'null': 1, }), + '609': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, }), 'GetAiAlarm': dict({ - '0': 5, - 'null': 5, + '0': 6, + 'null': 6, }), 'GetAiCfg': dict({ '0': 2, @@ -98,8 +102,8 @@ 'null': 1, }), 'GetAudioCfg': dict({ - '0': 2, - 'null': 2, + '0': 4, + 'null': 4, }), 'GetAutoFocus': dict({ '0': 1, @@ -192,8 +196,8 @@ 'null': 1, }), 'GetWhiteLed': dict({ - '0': 2, - 'null': 2, + '0': 3, + 'null': 3, }), 'GetZoomFocus': dict({ '0': 2, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 662469ebc01..00ef9e59e3b 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -338,24 +338,6 @@ async def test_removing_chime( "support_ch_uid", ), [ - ( - TEST_MAC, - f"{TEST_MAC}_firmware", - f"{TEST_MAC}", - f"{TEST_MAC}", - Platform.UPDATE, - False, - False, - ), - ( - TEST_MAC, - f"{TEST_UID}_firmware", - f"{TEST_MAC}", - f"{TEST_UID}", - Platform.UPDATE, - True, - False, - ), ( f"{TEST_MAC}_0_record_audio", f"{TEST_UID}_0_record_audio", diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index 80a0a7abeab..a9c2d8cc1bf 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -5,7 +5,11 @@ from unittest.mock import MagicMock, call, patch import pytest from reolink_aio.exceptions import InvalidParameterError, ReolinkError -from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -23,10 +27,10 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("whiteled_brightness", "expected_brightness"), + ("whiteled_brightness", "expected_brightness", "color_temp"), [ - (100, 255), - (None, None), + (100, 255, 3000), + (None, None, None), ], ) async def test_light_state( @@ -35,10 +39,19 @@ async def test_light_state( reolink_host: MagicMock, whiteled_brightness: int | None, expected_brightness: int | None, + color_temp: int | None, ) -> None: """Test light entity state with floodlight.""" + + def mock_supported(ch, capability): + if capability == "color_temp": + return color_temp is not None + return True + + reolink_host.supported = mock_supported reolink_host.whiteled_state.return_value = True reolink_host.whiteled_brightness.return_value = whiteled_brightness + reolink_host.whiteled_color_temperature.return_value = color_temp with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -50,6 +63,8 @@ async def test_light_state( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes["brightness"] == expected_brightness + if color_temp is not None: + assert state.attributes["color_temp_kelvin"] == color_temp async def test_light_turn_off( @@ -58,6 +73,8 @@ async def test_light_turn_off( reolink_host: MagicMock, ) -> None: """Test light turn off service.""" + reolink_host.whiteled_color_temperature.return_value = 3000 + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -89,6 +106,8 @@ async def test_light_turn_on( reolink_host: MagicMock, ) -> None: """Test light turn on service.""" + reolink_host.whiteled_color_temperature.return_value = 3000 + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -99,12 +118,13 @@ async def test_light_turn_on( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) reolink_host.set_whiteled.assert_has_calls( [call(0, brightness=20), call(0, state=True)] ) + reolink_host.baichuan.set_floodlight.assert_called_with(0, color_temp=4000) @pytest.mark.parametrize( diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 853edeefa5a..3e49a5dd4a7 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -147,13 +147,16 @@ async def test_host_number( ) +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_number( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_host: MagicMock, reolink_chime: Chime, + channel: int | None, ) -> None: """Test number entity of a chime with chime volume.""" + reolink_chime.channel = channel reolink_chime.volume = 3 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 5dcce747518..e74bcf8fc75 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -149,6 +149,7 @@ async def test_host_scene_select( assert hass.states.get(entity_id).state == STATE_UNKNOWN +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -156,8 +157,11 @@ async def test_chime_select( reolink_host: MagicMock, reolink_chime: Chime, entity_registry: er.EntityRegistry, + channel: int | None, ) -> None: """Test chime select entity.""" + reolink_chime.channel = channel + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -197,6 +201,7 @@ async def test_chime_select( # Test unavailable reolink_chime.event_info = {} + reolink_chime.update_enums() freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index 47e0e47e57f..c3ed7708f52 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -16,13 +16,14 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_ON, STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .conftest import TEST_CAM_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -39,7 +40,7 @@ async def test_siren( assert config_entry.state is ConfigEntryState.LOADED entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id).state == STATE_ON # test siren turn on await hass.services.async_call( @@ -134,3 +135,41 @@ async def test_siren_turn_off_errors( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_host_siren( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_host: MagicMock, +) -> None: + """Test siren entity.""" + config_entry.is_hub = True + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SIREN]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + # test siren turn on + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_host.set_hub_audio.assert_not_called() + reolink_host.set_siren.assert_called_with() + + reolink_host.set_siren.reset_mock() + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_VOLUME_LEVEL: 0.85}, + blocking=True, + ) + reolink_host.set_hub_audio.assert_called_with(alarm_volume=85) + reolink_host.set_siren.assert_not_called() diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index c8a38f19d5c..83840cace97 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -8,7 +8,6 @@ from reolink_aio.api import Chime from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL -from homeassistant.components.reolink.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -22,9 +21,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from .conftest import TEST_CAM_NAME, TEST_NVR_NAME, TEST_UID +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -164,14 +162,18 @@ async def test_host_switch( ) +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, reolink_host: MagicMock, reolink_chime: Chime, + channel: int | None, ) -> None: """Test host switch entity.""" + reolink_chime.channel = channel + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -224,141 +226,3 @@ async def test_chime_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - -@pytest.mark.parametrize( - ( - "original_id", - "capability", - ), - [ - ( - f"{TEST_UID}_record", - "recording", - ), - ( - f"{TEST_UID}_ftp_upload", - "ftp", - ), - ( - f"{TEST_UID}_push_notifications", - "push", - ), - ( - f"{TEST_UID}_email", - "email", - ), - ( - f"{TEST_UID}_buzzer", - "buzzer", - ), - ], -) -async def test_cleanup_hub_switches( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_host: MagicMock, - entity_registry: er.EntityRegistry, - original_id: str, - capability: str, -) -> None: - """Test entity ids that need to be migrated.""" - - def mock_supported(ch, cap): - if cap == capability: - return False - return True - - domain = Platform.SWITCH - - reolink_host.channels = [0] - reolink_host.is_hub = True - reolink_host.supported = mock_supported - - entity_registry.async_get_or_create( - domain=domain, - platform=DOMAIN, - unique_id=original_id, - config_entry=config_entry, - suggested_object_id=original_id, - disabled_by=er.RegistryEntryDisabler.USER, - ) - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - # setup CH 0 and host entities/device - with patch("homeassistant.components.reolink.PLATFORMS", [domain]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None - - -@pytest.mark.parametrize( - ( - "original_id", - "capability", - ), - [ - ( - f"{TEST_UID}_record", - "recording", - ), - ( - f"{TEST_UID}_ftp_upload", - "ftp", - ), - ( - f"{TEST_UID}_push_notifications", - "push", - ), - ( - f"{TEST_UID}_email", - "email", - ), - ( - f"{TEST_UID}_buzzer", - "buzzer", - ), - ], -) -async def test_hub_switches_repair_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_host: MagicMock, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - original_id: str, - capability: str, -) -> None: - """Test entity ids that need to be migrated.""" - - def mock_supported(ch, cap): - if cap == capability: - return False - return True - - domain = Platform.SWITCH - - reolink_host.channels = [0] - reolink_host.is_hub = True - reolink_host.supported = mock_supported - - entity_registry.async_get_or_create( - domain=domain, - platform=DOMAIN, - unique_id=original_id, - config_entry=config_entry, - suggested_object_id=original_id, - disabled_by=None, - ) - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - # setup CH 0 and host entities/device - with patch("homeassistant.components.reolink.PLATFORMS", [domain]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - assert (DOMAIN, "hub_switch_deprecated") in issue_registry.issues diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py index 01581c8ac68..a7f162e52c3 100644 --- a/tests/components/rest/test_data.py +++ b/tests/components/rest/test_data.py @@ -541,7 +541,7 @@ async def test_rest_data_boolean_params_converted_to_strings( # Check that the request was made with boolean values converted to strings assert len(aioclient_mock.mock_calls) == 1 - method, url, data, headers = aioclient_mock.mock_calls[0] + _method, url, _data, _headers = aioclient_mock.mock_calls[0] # Check that the URL query parameters have boolean values converted to strings assert url.query["boolTrue"] == "true" diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 7bd84bbcd70..9a89e9624dc 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1164,7 +1164,7 @@ async def test_query_param_json_string_preserved( # Verify the request was made with the JSON string intact assert len(aioclient_mock.mock_calls) == 1 - method, url, data, headers = aioclient_mock.mock_calls[0] + _method, url, _data, _headers = aioclient_mock.mock_calls[0] assert url.query["filter"] == '{"type": "sensor", "id": 123}' assert url.query["normal"] == "value" diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index b9c1096f26a..0c8f8a93f65 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -11,6 +11,7 @@ from homeassistant.components.rest_command import DOMAIN from homeassistant.const import ( CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN, + HTTP_DIGEST_AUTHENTICATION, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant @@ -123,6 +124,55 @@ async def test_rest_command_auth( assert len(aioclient_mock.mock_calls) == 1 +async def test_rest_command_digest_auth( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with HTTP digest authentication.""" + config = { + "digest_auth_test": { + "url": TEST_URL, + "method": "get", + "username": "test_user", + "password": "test_pass", + "authentication": HTTP_DIGEST_AUTHENTICATION, + } + } + + await setup_component(config) + + # Mock the digest auth behavior - the request will be called with DigestAuthMiddleware + with patch("aiohttp.ClientSession.get") as mock_get: + + async def async_iter_chunks(self, chunk_size): + yield b"success" + + mock_response = type( + "MockResponse", + (), + { + "status": 200, + "content_type": "text/plain", + "headers": {}, + "url": TEST_URL, + "content": type( + "MockContent", (), {"iter_chunked": async_iter_chunks} + )(), + }, + )() + mock_get.return_value.__aenter__.return_value = mock_response + + await hass.services.async_call(DOMAIN, "digest_auth_test", {}, blocking=True) + + # Verify that the request was made with DigestAuthMiddleware + assert mock_get.called + call_kwargs = mock_get.call_args[1] + assert "middlewares" in call_kwargs + assert len(call_kwargs["middlewares"]) == 1 + assert isinstance(call_kwargs["middlewares"][0], aiohttp.DigestAuthMiddleware) + + async def test_rest_command_form_data( hass: HomeAssistant, setup_component: ComponentSetup, diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index 8f2b3961242..736ad4c73cf 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -547,7 +547,7 @@ async def test_unique_id( } # setup mocking rflink module - event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) + _event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) humidity_entry = entity_registry.async_get("sensor.humidity_device") assert humidity_entry @@ -569,7 +569,7 @@ async def test_enable_debug_logs( config = {DOMAIN: {CONF_HOST: "10.10.0.1", CONF_PORT: 1234}} # setup mocking rflink module - _, mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) + _, _mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) logging.getLogger("rflink").setLevel(logging.DEBUG) hass.bus.async_fire(EVENT_LOGGING_CHANGED) diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py index 278dd45a114..2f0164a55f9 100644 --- a/tests/components/rflink/test_sensor.py +++ b/tests/components/rflink/test_sensor.py @@ -287,7 +287,7 @@ async def test_sensor_attributes( } # setup mocking rflink module - event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) + _event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) # test sensor loaded from config meter_state = hass.states.get("sensor.meter_device") diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index f95e4795d1d..ea569399ace 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -1,11 +1,12 @@ """Global fixtures for Roborock integration.""" +import asyncio from collections.abc import Generator from copy import deepcopy import pathlib import tempfile from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch import pytest from roborock import RoborockCategory, RoomMapping @@ -70,6 +71,9 @@ class A01Mock(RoborockMqttClientA01): @pytest.fixture(name="bypass_api_client_fixture") def bypass_api_client_fixture() -> None: """Skip calls to the API client.""" + base_url_future = asyncio.Future() + base_url_future.set_result(BASE_URL) + with ( patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", @@ -82,12 +86,17 @@ def bypass_api_client_fixture() -> None: patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.load_multi_map" ), + patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.base_url", + new_callable=PropertyMock, + return_value=base_url_future, + ), ): yield @pytest.fixture(name="bypass_api_fixture") -def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: +def bypass_api_fixture(bypass_api_client_fixture: Any, mock_send_message: Mock) -> None: """Skip calls to the API.""" with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), @@ -95,6 +104,9 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.async_connect" + ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", return_value=NETWORK_INFO, @@ -116,7 +128,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: return_value=MAP_DATA, ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message" + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_message" ), patch("homeassistant.components.roborock.RoborockMqttClientV1._wait_response"), patch( diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index cf4f167ef7f..1495dcb686c 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -61,7 +61,7 @@ USER_DATA = UserData.from_dict( MOCK_CONFIG = { CONF_USERNAME: USER_EMAIL, CONF_USER_DATA: USER_DATA.as_dict(), - CONF_BASE_URL: None, + CONF_BASE_URL: BASE_URL, } HOME_DATA_RAW = { diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 26ecb729312..bf7fbfaadc3 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -32,8 +32,6 @@ 'coordinators': dict({ '**REDACTED-0**': dict({ 'api': dict({ - 'misc_info': dict({ - }), }), 'roborock_device_info': dict({ 'device': dict({ @@ -218,9 +216,9 @@ 'squareMeterCleanArea': 1159.2, }), 'consumable': dict({ - 'cleaningBrushTimeLeft': 1079935, + 'cleaningBrushTimeLeft': 235, 'cleaningBrushWorkTimes': 65, - 'dustCollectionTimeLeft': 80975, + 'dustCollectionTimeLeft': 65, 'dustCollectionWorkTimes': 25, 'filterElementWorkTime': 0, 'filterTimeLeft': 465618, @@ -231,7 +229,7 @@ 'sensorTimeLeft': 33618, 'sideBrushTimeLeft': 645618, 'sideBrushWorkTime': 74382, - 'strainerTimeLeft': 539935, + 'strainerTimeLeft': 85, 'strainerWorkTimes': 65, }), 'lastCleanRecord': dict({ @@ -319,8 +317,6 @@ }), '**REDACTED-1**': dict({ 'api': dict({ - 'misc_info': dict({ - }), }), 'roborock_device_info': dict({ 'device': dict({ @@ -505,9 +501,9 @@ 'squareMeterCleanArea': 1159.2, }), 'consumable': dict({ - 'cleaningBrushTimeLeft': 1079935, + 'cleaningBrushTimeLeft': 235, 'cleaningBrushWorkTimes': 65, - 'dustCollectionTimeLeft': 80975, + 'dustCollectionTimeLeft': 65, 'dustCollectionWorkTimes': 25, 'filterElementWorkTime': 0, 'filterTimeLeft': 465618, @@ -518,7 +514,7 @@ 'sensorTimeLeft': 33618, 'sideBrushTimeLeft': 645618, 'sideBrushWorkTime': 74382, - 'strainerTimeLeft': 539935, + 'strainerTimeLeft': 85, 'strainerWorkTimes': 65, }), 'lastCleanRecord': dict({ @@ -606,8 +602,6 @@ }), '**REDACTED-2**': dict({ 'api': dict({ - 'misc_info': dict({ - }), }), 'roborock_device_info': dict({ 'device': dict({ @@ -969,8 +963,6 @@ }), '**REDACTED-3**': dict({ 'api': dict({ - 'misc_info': dict({ - }), }), 'roborock_device_info': dict({ 'device': dict({ diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 77c5d4d7cb0..7dc15c02bc4 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -1,10 +1,10 @@ """Test Roborock Button platform.""" -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch import pytest -import roborock from roborock import RoborockException +from roborock.exceptions import RoborockTimeout from homeassistant.components.button import SERVICE_PRESS from homeassistant.const import Platform @@ -48,19 +48,17 @@ async def test_update_success( bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, + mock_send_message: Mock, ) -> None: """Test pressing the button entities.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id).state == "unknown" - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message" - ) as mock_send_message: - await hass.services.async_call( - "button", - SERVICE_PRESS, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" @@ -73,21 +71,19 @@ async def test_update_success( ) @pytest.mark.freeze_time("2023-10-30 08:50:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("send_message_side_effect", [RoborockTimeout]) async def test_update_failure( hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, + mock_send_message: Mock, ) -> None: """Test failure while pressing the button entity.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id).state == "unknown" - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, - pytest.raises(HomeAssistantError, match="Error while calling RESET_CONSUMABLE"), + with pytest.raises( + HomeAssistantError, match="Error while calling RESET_CONSUMABLE" ): await hass.services.async_call( "button", diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 6974bc5fccc..125476b0edd 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -46,7 +46,7 @@ async def test_config_flow_success( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code_v4" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USER_EMAIL} @@ -56,7 +56,7 @@ async def test_config_flow_success( assert result["step_id"] == "code" assert result["errors"] == {} with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login_v4", return_value=USER_DATA, ): result = await hass.config_entries.flow.async_configure( @@ -101,7 +101,7 @@ async def test_config_flow_failures_request_code( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code", + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code_v4", side_effect=request_code_side_effect, ): result = await hass.config_entries.flow.async_configure( @@ -111,7 +111,7 @@ async def test_config_flow_failures_request_code( assert result["errors"] == request_code_errors # Recover from error with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code_v4" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USER_EMAIL} @@ -121,7 +121,7 @@ async def test_config_flow_failures_request_code( assert result["step_id"] == "code" assert result["errors"] == {} with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login_v4", return_value=USER_DATA, ): result = await hass.config_entries.flow.async_configure( @@ -163,7 +163,7 @@ async def test_config_flow_failures_code_login( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code_v4" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USER_EMAIL} @@ -174,7 +174,7 @@ async def test_config_flow_failures_code_login( assert result["errors"] == {} # Raise exception for invalid code with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login_v4", side_effect=code_login_side_effect, ): result = await hass.config_entries.flow.async_configure( @@ -183,7 +183,7 @@ async def test_config_flow_failures_code_login( assert result["type"] is FlowResultType.FORM assert result["errors"] == code_login_errors with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login_v4", return_value=USER_DATA, ): result = await hass.config_entries.flow.async_configure( @@ -230,18 +230,13 @@ async def test_reauth_flow( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry ) -> None: """Test reauth flow.""" - # Start reauth - result = mock_roborock_entry.async_start_reauth(hass) - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - [result] = flows + result = await mock_roborock_entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" # Request a new code with ( patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code_v4" ), patch("homeassistant.components.roborock.async_setup_entry", return_value=True), ): @@ -255,7 +250,7 @@ async def test_reauth_flow( new_user_data.rriot.s = "new_password_hash" with ( patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login_v4", return_value=new_user_data, ), patch("homeassistant.components.roborock.async_setup_entry", return_value=True), @@ -285,7 +280,7 @@ async def test_account_already_configured( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code_v4" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USER_EMAIL} @@ -294,7 +289,7 @@ async def test_account_already_configured( assert result["step_id"] == "code" assert result["type"] is FlowResultType.FORM with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login_v4", return_value=USER_DATA, ): result = await hass.config_entries.flow.async_configure( @@ -311,19 +306,14 @@ async def test_reauth_wrong_account( ) -> None: """Ensure that reauthentication must use the same account.""" - # Start reauth - result = mock_roborock_entry.async_start_reauth(hass) - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - [result] = flows + result = await mock_roborock_entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" with patch( "homeassistant.components.roborock.async_setup_entry", return_value=True ): with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code_v4" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USER_EMAIL} @@ -334,7 +324,7 @@ async def test_reauth_wrong_account( new_user_data = deepcopy(USER_DATA) new_user_data.rruid = "new_rruid" with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login_v4", return_value=new_user_data, ): result = await hass.config_entries.flow.async_configure( @@ -364,7 +354,7 @@ async def test_discovery_not_setup( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code_v4" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USER_EMAIL} @@ -374,7 +364,7 @@ async def test_discovery_not_setup( assert result["step_id"] == "code" assert result["errors"] == {} with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login_v4", return_value=USER_DATA, ): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index dec4e0a62d4..315ab14bdb5 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -5,14 +5,18 @@ from datetime import timedelta from unittest.mock import patch import pytest +from roborock import MultiMapsList from roborock.exceptions import RoborockException +from vacuum_map_parser_base.config.color import SupportedColor from homeassistant.components.roborock.const import ( + CONF_SHOW_BACKGROUND, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) +from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -73,6 +77,26 @@ async def test_dynamic_cloud_scan_interval( assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" +async def test_visible_background( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture: None, +) -> None: + """Test that a visible background is handled correctly.""" + hass.config_entries.async_update_entry( + mock_roborock_entry, + options={ + CONF_SHOW_BACKGROUND: True, + }, + ) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + coordinator: RoborockDataUpdateCoordinator = mock_roborock_entry.runtime_data.v1[0] + assert coordinator.map_parser._palette.get_color( # pylint: disable=protected-access + SupportedColor.MAP_OUTSIDE + ) != (0, 0, 0, 0) + + @pytest.mark.parametrize( ("interval", "in_cleaning"), [ @@ -112,3 +136,30 @@ async def test_dynamic_local_scan_interval( async_fire_time_changed(hass, dt_util.utcnow() + interval) assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" + + +async def test_no_maps( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture: None, +) -> None: + """Test that a device with no maps is handled correctly.""" + prop = copy.deepcopy(PROP) + prop.status.map_status = 252 + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", + return_value=MultiMapsList( + max_multi_map=1, max_bak_map=1, multi_map_count=0, map_info=[] + ), + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map" + ) as load_map, + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert load_map.call_count == 0 diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index bfd8cc6da2b..c4809a71b6e 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -1,9 +1,9 @@ """Test Roborock Number platform.""" -from unittest.mock import patch +from unittest.mock import Mock import pytest -import roborock +from roborock.exceptions import RoborockTimeout from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.const import Platform @@ -31,20 +31,18 @@ async def test_update_success( setup_entry: MockConfigEntry, entity_id: str, value: float, + mock_send_message: Mock, ) -> None: """Test allowed changing values for number entities.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message" - ) as mock_send_message: - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - service_data={ATTR_VALUE: value}, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: value}, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -54,23 +52,19 @@ async def test_update_success( ("number.roborock_s7_maxv_volume", 3.0), ], ) +@pytest.mark.parametrize("send_message_side_effect", [RoborockTimeout]) async def test_update_failed( hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, value: float, + mock_send_message: Mock, ) -> None: """Test allowed changing values for number entities.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, - pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), - ): + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "number", SERVICE_SET_VALUE, diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 7f25141306b..04b3be99575 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -1,7 +1,7 @@ """Test Roborock Select platform.""" import copy -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from roborock.exceptions import RoborockException @@ -37,36 +37,30 @@ async def test_update_success( setup_entry: MockConfigEntry, entity_id: str, value: str, + mock_send_message: Mock, ) -> None: """Test allowed changing values for select entities.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message" - ) as mock_send_message: - await hass.services.async_call( - "select", - SERVICE_SELECT_OPTION, - service_data={"option": value}, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once +@pytest.mark.parametrize("send_message_side_effect", [RoborockException]) async def test_update_failure( hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry, + mock_send_message: Mock, ) -> None: """Test that changing a value will raise a homeassistanterror when it fails.""" - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message", - side_effect=RoborockException(), - ), - pytest.raises(HomeAssistantError, match="Error while calling SET_MOP_MOD"), - ): + with pytest.raises(HomeAssistantError, match="Error while calling SET_MOP_MOD"): await hass.services.async_call( "select", SERVICE_SELECT_OPTION, diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 719b398de94..623fde93b1f 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -5,10 +5,12 @@ from unittest.mock import patch import pytest from roborock import DeviceData, HomeDataDevice from roborock.const import ( + CLEANING_BRUSH_REPLACE_TIME, FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME, + STRAINER_REPLACE_TIME, ) from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.version_1_apis import RoborockMqttClientV1 @@ -29,7 +31,7 @@ def platforms() -> list[Platform]: async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 42 + assert len(hass.states.async_all("sensor")) == 46 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -42,6 +44,13 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_sensor_time_left").state == str( SENSOR_DIRTY_REPLACE_TIME - 74382 ) + assert hass.states.get( + "sensor.roborock_s7_2_dock_maintenance_brush_time_left" + ).state == str(CLEANING_BRUSH_REPLACE_TIME - 65) + assert hass.states.get("sensor.roborock_s7_2_dock_strainer_time_left").state == str( + STRAINER_REPLACE_TIME - 65 + ) + assert hass.states.get("sensor.roborock_s7_maxv_cleaning_time").state == "1176" assert ( hass.states.get("sensor.roborock_s7_maxv_total_cleaning_time").state == "74382" diff --git a/tests/components/roomba/conftest.py b/tests/components/roomba/conftest.py new file mode 100644 index 00000000000..aa89ff9f56a --- /dev/null +++ b/tests/components/roomba/conftest.py @@ -0,0 +1,62 @@ +"""Fixtures for the Roomba tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from roombapy import Roomba + +from homeassistant.components.roomba import CONF_BLID, CONF_CONTINUOUS, DOMAIN +from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.0.30", + CONF_BLID: "blid123", + CONF_PASSWORD: "pass123", + }, + options={ + CONF_CONTINUOUS: True, + CONF_DELAY: 10, + }, + unique_id="blid123", + ) + + +@pytest.fixture +def mock_roomba() -> Generator[AsyncMock]: + """Build a fixture for the 17Track API.""" + mock_roomba = AsyncMock(spec=Roomba, autospec=True) + mock_roomba.master_state = { + "state": { + "reported": { + "cap": {"pose": 1}, + "cleanMissionStatus": {"cycle": "none", "phase": "charge"}, + "pose": {"point": {"x": 1, "y": 2}, "theta": 90}, + "dock": {"tankLvl": 99}, + "hwPartsRev": { + "navSerialNo": "12345", + "wlan0HwAddr": "AA:BB:CC:DD:EE:FF", + }, + "sku": "980", + "name": "Test Roomba", + "softwareVer": "3.2.1", + "hardwareRev": "1.0", + "bin": {"present": True, "full": False}, + } + } + } + mock_roomba.roomba_connected = True + + with patch( + "homeassistant.components.roomba.RoombaFactory.create_roomba", + return_value=mock_roomba, + ): + yield mock_roomba diff --git a/tests/components/roomba/snapshots/test_sensor.ambr b/tests/components/roomba/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..19a1ae58d0e --- /dev/null +++ b/tests/components/roomba/snapshots/test_sensor.ambr @@ -0,0 +1,659 @@ +# serializer version: 1 +# name: test_entities[sensor.test_roomba_average_mission_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_average_mission_time', + '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': 'Average mission time', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_mission_time', + 'unique_id': 'average_mission_time_blid123', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.test_roomba_average_mission_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Average mission time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_roomba_average_mission_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_battery-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.test_roomba_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_blid123', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.test_roomba_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Roomba Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_battery_cycles-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': , + 'entity_id': 'sensor.test_roomba_battery_cycles', + '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': 'Battery cycles', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_cycles', + 'unique_id': 'battery_cycles_blid123', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.test_roomba_battery_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Battery cycles', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_roomba_battery_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_canceled_missions-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': , + 'entity_id': 'sensor.test_roomba_canceled_missions', + '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': 'Canceled missions', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'canceled_missions', + 'unique_id': 'canceled_missions_blid123', + 'unit_of_measurement': 'Missions', + }) +# --- +# name: test_entities[sensor.test_roomba_canceled_missions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Canceled missions', + 'state_class': , + 'unit_of_measurement': 'Missions', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_canceled_missions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_dock_tank_level-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.test_roomba_dock_tank_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': 'Dock tank level', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_tank_level', + 'unique_id': 'dock_tank_level_blid123', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.test_roomba_dock_tank_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Dock tank level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_dock_tank_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_entities[sensor.test_roomba_failed_missions-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': , + 'entity_id': 'sensor.test_roomba_failed_missions', + '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': 'Failed missions', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'failed_missions', + 'unique_id': 'failed_missions_blid123', + 'unit_of_measurement': 'Missions', + }) +# --- +# name: test_entities[sensor.test_roomba_failed_missions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Failed missions', + 'state_class': , + 'unit_of_measurement': 'Missions', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_failed_missions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_last_mission_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_last_mission_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last mission start time', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_mission', + 'unique_id': 'last_mission_blid123', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.test_roomba_last_mission_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Roomba Last mission start time', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_last_mission_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_scrubs-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': , + 'entity_id': 'sensor.test_roomba_scrubs', + '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': 'Scrubs', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'scrubs_count', + 'unique_id': 'scrubs_count_blid123', + 'unit_of_measurement': 'Scrubs', + }) +# --- +# name: test_entities[sensor.test_roomba_scrubs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Scrubs', + 'state_class': , + 'unit_of_measurement': 'Scrubs', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_scrubs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_successful_missions-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': , + 'entity_id': 'sensor.test_roomba_successful_missions', + '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': 'Successful missions', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'successful_missions', + 'unique_id': 'successful_missions_blid123', + 'unit_of_measurement': 'Missions', + }) +# --- +# name: test_entities[sensor.test_roomba_successful_missions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Successful missions', + 'state_class': , + 'unit_of_measurement': 'Missions', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_successful_missions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_tank_level-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.test_roomba_tank_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': 'Tank level', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tank_level', + 'unique_id': 'tank_level_blid123', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.test_roomba_tank_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Tank level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_tank_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_total_cleaned_area-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.test_roomba_total_cleaned_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaned area', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaned_area', + 'unique_id': 'total_cleaned_area_blid123', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.test_roomba_total_cleaned_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Total cleaned area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_roomba_total_cleaned_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_total_cleaning_time', + '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': 'Total cleaning time', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'total_cleaning_time_blid123', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.test_roomba_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Total cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_roomba_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_total_missions-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': , + 'entity_id': 'sensor.test_roomba_total_missions', + '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': 'Total missions', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_missions', + 'unique_id': 'total_missions_blid123', + 'unit_of_measurement': 'Missions', + }) +# --- +# name: test_entities[sensor.test_roomba_total_missions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Total missions', + 'state_class': , + 'unit_of_measurement': 'Missions', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_total_missions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/roomba/test_sensor.py b/tests/components/roomba/test_sensor.py new file mode 100644 index 00000000000..fd56a6e9b3f --- /dev/null +++ b/tests/components/roomba/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for IRobotEntity usage in Roomba sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test roomba entities.""" + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/route_b_smart_meter/__init__.py b/tests/components/route_b_smart_meter/__init__.py new file mode 100644 index 00000000000..7b998b1f4bd --- /dev/null +++ b/tests/components/route_b_smart_meter/__init__.py @@ -0,0 +1 @@ +"""Tests for the Smart Meter B-route integration.""" diff --git a/tests/components/route_b_smart_meter/conftest.py b/tests/components/route_b_smart_meter/conftest.py new file mode 100644 index 00000000000..f0a84c252a0 --- /dev/null +++ b/tests/components/route_b_smart_meter/conftest.py @@ -0,0 +1,72 @@ +"""Common fixtures for the Smart Meter B-route tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.route_b_smart_meter.const import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.route_b_smart_meter.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_momonga(exception=None) -> Generator[Mock]: + """Mock for Momonga class.""" + + with ( + patch( + "homeassistant.components.route_b_smart_meter.coordinator.Momonga", + ) as mock_momonga, + patch( + "homeassistant.components.route_b_smart_meter.config_flow.Momonga", + new=mock_momonga, + ), + ): + client = mock_momonga.return_value + client.__enter__.return_value = client + client.__exit__.return_value = None + client.get_instantaneous_current.return_value = { + "r phase current": 1, + "t phase current": 2, + } + client.get_instantaneous_power.return_value = 3 + client.get_measured_cumulative_energy.return_value = 4 + yield mock_momonga + + +@pytest.fixture +def user_input() -> dict[str, str]: + """Return test user input data.""" + return { + CONF_DEVICE: "/dev/ttyUSB42", + CONF_ID: "01234567890123456789012345F789", + CONF_PASSWORD: "B_ROUTE_PASSWORD", + } + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, user_input: dict[str, str] +) -> MockConfigEntry: + """Create a mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=user_input, + entry_id="01234567890123456789012345F789", + unique_id="123456", + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr b/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..552e46aa687 --- /dev/null +++ b/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase-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.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous current R phase', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_current_r_phase', + 'unique_id': '01234567890123456789012345F789_instantaneous_current_r_phase', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous current R phase', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase-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.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous current T phase', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_current_t_phase', + 'unique_id': '01234567890123456789012345F789_instantaneous_current_t_phase', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous current T phase', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power-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.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous power', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_power', + 'unique_id': '01234567890123456789012345F789_instantaneous_power', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption-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.route_b_smart_meter_01234567890123456789012345f789_total_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total consumption', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_consumption', + 'unique_id': '01234567890123456789012345F789_total_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Total consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/route_b_smart_meter/test_config_flow.py b/tests/components/route_b_smart_meter/test_config_flow.py new file mode 100644 index 00000000000..d7dc84a9999 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_config_flow.py @@ -0,0 +1,111 @@ +"""Test the Smart Meter B-route config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from momonga import MomongaSkJoinFailure, MomongaSkScanFailure +import pytest +from serial.tools.list_ports_linux import SysFS + +from homeassistant.components.route_b_smart_meter.const import DOMAIN, ENTRY_TITLE +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +def mock_comports() -> Generator[AsyncMock]: + """Override comports.""" + device = SysFS("/dev/ttyUSB42") + device.vid = 0x1234 + device.pid = 0x5678 + device.serial_number = "123456" + device.manufacturer = "Test" + device.description = "Test Device" + + with patch( + "homeassistant.components.route_b_smart_meter.config_flow.comports", + return_value=[SysFS("/dev/ttyUSB41"), device], + ) as mock: + yield mock + + +async def test_step_user_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_comports: AsyncMock, + mock_momonga: Mock, + user_input: dict[str, str], +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ENTRY_TITLE + assert result["data"] == user_input + assert result["result"].unique_id == user_input[CONF_ID] + mock_setup_entry.assert_called_once() + mock_comports.assert_called() + mock_momonga.assert_called_once_with( + dev=user_input[CONF_DEVICE], + rbid=user_input[CONF_ID], + pwd=user_input[CONF_PASSWORD], + ) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (MomongaSkJoinFailure, "invalid_auth"), + (MomongaSkScanFailure, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_step_user_form_errors( + hass: HomeAssistant, + error: Exception, + message: str, + mock_setup_entry: AsyncMock, + mock_comports: AsyncMock, + mock_momonga: AsyncMock, + user_input: dict[str, str], +) -> None: + """Test we handle error.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_momonga.side_effect = error + result_configure = await hass.config_entries.flow.async_configure( + result_init["flow_id"], + user_input, + ) + + assert result_configure["type"] is FlowResultType.FORM + assert result_configure["errors"] == {"base": message} + await hass.async_block_till_done() + mock_comports.assert_called() + mock_momonga.assert_called_once_with( + dev=user_input[CONF_DEVICE], + rbid=user_input[CONF_ID], + pwd=user_input[CONF_PASSWORD], + ) + + mock_momonga.side_effect = None + result = await hass.config_entries.flow.async_configure( + result_configure["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ENTRY_TITLE + assert result["data"] == user_input diff --git a/tests/components/route_b_smart_meter/test_init.py b/tests/components/route_b_smart_meter/test_init.py new file mode 100644 index 00000000000..644fda84886 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Smart Meter B Route integration init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry_success( + hass: HomeAssistant, mock_momonga, mock_config_entry: MockConfigEntry +) -> None: + """Test successful setup of entry.""" + 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/route_b_smart_meter/test_sensor.py b/tests/components/route_b_smart_meter/test_sensor.py new file mode 100644 index 00000000000..63d9cac0449 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests for the Smart Meter B-Route sensor.""" + +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +from momonga import MomongaError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.route_b_smart_meter.const import DEFAULT_SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_route_b_smart_meter_sensor_update( + hass: HomeAssistant, + mock_momonga: Mock, + freezer: FrozenDateTimeFactory, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the BRouteUpdateCoordinator successful behavior.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_route_b_smart_meter_sensor_no_update( + hass: HomeAssistant, + mock_momonga: Mock, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the BRouteUpdateCoordinator when failing.""" + + entity_id = "sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity.state == "1" + + mock_momonga.return_value.get_instantaneous_current.side_effect = MomongaError + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity = hass.states.get(entity_id) + assert entity.state is STATE_UNAVAILABLE diff --git a/tests/components/satel_integra/__init__.py b/tests/components/satel_integra/__init__.py new file mode 100644 index 00000000000..97b8b4be493 --- /dev/null +++ b/tests/components/satel_integra/__init__.py @@ -0,0 +1,68 @@ +"""The tests for Satel Integra integration.""" + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.satel_integra import ( + CONF_ARM_HOME_MODE, + CONF_OUTPUT_NUMBER, + CONF_PARTITION_NUMBER, + CONF_SWITCHABLE_OUTPUT_NUMBER, + CONF_ZONE_NUMBER, + CONF_ZONE_TYPE, + SUBENTRY_TYPE_OUTPUT, + SUBENTRY_TYPE_PARTITION, + SUBENTRY_TYPE_SWITCHABLE_OUTPUT, + SUBENTRY_TYPE_ZONE, +) +from homeassistant.components.satel_integra.const import DEFAULT_PORT +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT + +MOCK_CONFIG_DATA = {CONF_HOST: "192.168.0.2", CONF_PORT: DEFAULT_PORT} +MOCK_CONFIG_OPTIONS = {CONF_CODE: "1234"} + +MOCK_PARTITION_SUBENTRY = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_PARTITION, + subentry_id="ID_PARTITION", + unique_id="partition_1", + title="Home", + data={ + CONF_NAME: "Home", + CONF_ARM_HOME_MODE: 1, + CONF_PARTITION_NUMBER: 1, + }, +) + +MOCK_ZONE_SUBENTRY = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_ZONE, + subentry_id="ID_ZONE", + unique_id="zone_1", + title="Zone 1", + data={ + CONF_NAME: "Zone 1", + CONF_ZONE_TYPE: BinarySensorDeviceClass.MOTION, + CONF_ZONE_NUMBER: 1, + }, +) + +MOCK_OUTPUT_SUBENTRY = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_OUTPUT, + subentry_id="ID_OUTPUT", + unique_id="output_1", + title="Output 1", + data={ + CONF_NAME: "Output 1", + CONF_ZONE_TYPE: BinarySensorDeviceClass.SAFETY, + CONF_OUTPUT_NUMBER: 1, + }, +) + +MOCK_SWITCHABLE_OUTPUT_SUBENTRY = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_SWITCHABLE_OUTPUT, + subentry_id="ID_SWITCHABLE_OUTPUT", + unique_id="switchable_output_1", + title="Switchable Output 1", + data={ + CONF_NAME: "Switchable Output 1", + CONF_SWITCHABLE_OUTPUT_NUMBER: 1, + }, +) diff --git a/tests/components/satel_integra/conftest.py b/tests/components/satel_integra/conftest.py new file mode 100644 index 00000000000..a468ecd18d8 --- /dev/null +++ b/tests/components/satel_integra/conftest.py @@ -0,0 +1,78 @@ +"""Satel Integra tests configuration.""" + +from collections.abc import Generator +from copy import deepcopy +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.satel_integra.const import DOMAIN + +from . import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, + MOCK_OUTPUT_SUBENTRY, + MOCK_PARTITION_SUBENTRY, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + MOCK_ZONE_SUBENTRY, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override integration setup.""" + with patch( + "homeassistant.components.satel_integra.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_satel() -> Generator[AsyncMock]: + """Override the satel test.""" + with ( + patch( + "homeassistant.components.satel_integra.AsyncSatel", + autospec=True, + ) as client, + patch( + "homeassistant.components.satel_integra.config_flow.AsyncSatel", new=client + ), + ): + client.return_value.partition_states = {} + client.return_value.violated_outputs = [] + client.return_value.violated_zones = [] + client.return_value.connect.return_value = True + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock satel configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="192.168.0.2", + data=MOCK_CONFIG_DATA, + options=MOCK_CONFIG_OPTIONS, + entry_id="SATEL_INTEGRA_CONFIG_ENTRY_1", + ) + + +@pytest.fixture +def mock_config_entry_with_subentries( + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Mock satel configuration entry.""" + mock_config_entry.subentries = deepcopy( + { + MOCK_PARTITION_SUBENTRY.subentry_id: MOCK_PARTITION_SUBENTRY, + MOCK_ZONE_SUBENTRY.subentry_id: MOCK_ZONE_SUBENTRY, + MOCK_OUTPUT_SUBENTRY.subentry_id: MOCK_OUTPUT_SUBENTRY, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY.subentry_id: MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + } + ) + return mock_config_entry diff --git a/tests/components/satel_integra/snapshots/test_diagnostics.ambr b/tests/components/satel_integra/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b6c99772c80 --- /dev/null +++ b/tests/components/satel_integra/snapshots/test_diagnostics.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'host': '192.168.0.2', + 'port': 7094, + }), + 'config_entry_options': dict({ + 'code': '**REDACTED**', + }), + 'subentries': dict({ + 'ID_OUTPUT': dict({ + 'data': dict({ + 'name': 'Output 1', + 'output_number': 1, + 'type': 'safety', + }), + 'subentry_id': 'ID_OUTPUT', + 'subentry_type': 'output', + 'title': 'Output 1', + 'unique_id': 'output_1', + }), + 'ID_PARTITION': dict({ + 'data': dict({ + 'arm_home_mode': 1, + 'name': 'Home', + 'partition_number': 1, + }), + 'subentry_id': 'ID_PARTITION', + 'subentry_type': 'partition', + 'title': 'Home', + 'unique_id': 'partition_1', + }), + 'ID_SWITCHABLE_OUTPUT': dict({ + 'data': dict({ + 'name': 'Switchable Output 1', + 'switchable_output_number': 1, + }), + 'subentry_id': 'ID_SWITCHABLE_OUTPUT', + 'subentry_type': 'switchable_output', + 'title': 'Switchable Output 1', + 'unique_id': 'switchable_output_1', + }), + 'ID_ZONE': dict({ + 'data': dict({ + 'name': 'Zone 1', + 'type': 'motion', + 'zone_number': 1, + }), + 'subentry_id': 'ID_ZONE', + 'subentry_type': 'zone', + 'title': 'Zone 1', + 'unique_id': 'zone_1', + }), + }), + }) +# --- diff --git a/tests/components/satel_integra/test_config_flow.py b/tests/components/satel_integra/test_config_flow.py new file mode 100644 index 00000000000..84b4aef2009 --- /dev/null +++ b/tests/components/satel_integra/test_config_flow.py @@ -0,0 +1,407 @@ +"""Test the satel integra config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.satel_integra.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_PORT, + DOMAIN, +) +from homeassistant.config_entries import ( + SOURCE_IMPORT, + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigSubentry, +) +from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, + MOCK_OUTPUT_SUBENTRY, + MOCK_PARTITION_SUBENTRY, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + MOCK_ZONE_SUBENTRY, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("user_input", "entry_data", "entry_options"), + [ + ( + {**MOCK_CONFIG_DATA, **MOCK_CONFIG_OPTIONS}, + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, + ), + ( + {CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST]}, + {CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST], CONF_PORT: DEFAULT_PORT}, + {CONF_CODE: None}, + ), + ], +) +async def test_setup_flow( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_setup_entry: AsyncMock, + user_input: dict[str, Any], + entry_data: dict[str, Any], + entry_options: dict[str, Any], +) -> None: + """Test the setup flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_CONFIG_DATA[CONF_HOST] + assert result["data"] == entry_data + assert result["options"] == entry_options + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_setup_connection_failed( + hass: HomeAssistant, mock_satel: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the setup flow when connection fails.""" + user_input = MOCK_CONFIG_DATA + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_satel.return_value.connect.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_satel.return_value.connect.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("import_input", "entry_data", "entry_options"), + [ + ( + { + CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST], + CONF_PORT: MOCK_CONFIG_DATA[CONF_PORT], + CONF_CODE: MOCK_CONFIG_OPTIONS[CONF_CODE], + CONF_DEVICE_PARTITIONS: { + "1": {CONF_NAME: "Partition Import 1", CONF_ARM_HOME_MODE: 1} + }, + CONF_ZONES: { + "1": {CONF_NAME: "Zone Import 1", CONF_ZONE_TYPE: "motion"}, + "2": {CONF_NAME: "Zone Import 2", CONF_ZONE_TYPE: "door"}, + }, + CONF_OUTPUTS: { + "1": {CONF_NAME: "Output Import 1", CONF_ZONE_TYPE: "light"}, + "2": {CONF_NAME: "Output Import 2", CONF_ZONE_TYPE: "safety"}, + }, + CONF_SWITCHABLE_OUTPUTS: { + "1": {CONF_NAME: "Switchable output Import 1"}, + "2": {CONF_NAME: "Switchable output Import 2"}, + }, + }, + MOCK_CONFIG_DATA, + MOCK_CONFIG_OPTIONS, + ) + ], +) +async def test_import_flow( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_setup_entry: AsyncMock, + import_input: dict[str, Any], + entry_data: dict[str, Any], + entry_options: dict[str, Any], +) -> None: + """Test the import flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=import_input + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_CONFIG_DATA[CONF_HOST] + assert result["data"] == entry_data + assert result["options"] == entry_options + + assert len(result["subentries"]) == 7 + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_connection_failure( + hass: HomeAssistant, mock_satel: AsyncMock +) -> None: + """Test the import flow.""" + + mock_satel.return_value.connect.return_value = False + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_CONFIG_DATA, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("user_input", "entry_options"), + [ + (MOCK_CONFIG_OPTIONS, MOCK_CONFIG_OPTIONS), + ({}, {CONF_CODE: None}), + ], +) +async def test_options_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + user_input: dict[str, Any], + entry_options: dict[str, Any], +) -> None: + """Test general options flow.""" + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.options == entry_options + + +@pytest.mark.parametrize( + ("user_input", "subentry"), + [ + (MOCK_PARTITION_SUBENTRY.data, MOCK_PARTITION_SUBENTRY), + (MOCK_ZONE_SUBENTRY.data, MOCK_ZONE_SUBENTRY), + (MOCK_OUTPUT_SUBENTRY.data, MOCK_OUTPUT_SUBENTRY), + (MOCK_SWITCHABLE_OUTPUT_SUBENTRY.data, MOCK_SWITCHABLE_OUTPUT_SUBENTRY), + ], +) +async def test_subentry_creation( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry: MockConfigEntry, + user_input: dict[str, Any], + subentry: ConfigSubentry, +) -> None: + """Test partitions options flow.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, subentry.subentry_type), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_config_entry.subentries) == 1 + + subentry_id = list(mock_config_entry.subentries)[0] + + subentry_result = { + **subentry.as_dict(), + "subentry_id": subentry_id, + } + assert mock_config_entry.subentries.get(subentry_id) == ConfigSubentry( + **subentry_result + ) + + +@pytest.mark.parametrize( + ( + "user_input", + "subentry", + ), + [ + ( + {CONF_NAME: "New Home", CONF_ARM_HOME_MODE: 3}, + MOCK_PARTITION_SUBENTRY, + ), + ( + {CONF_NAME: "Backdoor", CONF_ZONE_TYPE: BinarySensorDeviceClass.DOOR}, + MOCK_ZONE_SUBENTRY, + ), + ( + { + CONF_NAME: "Alarm Triggered", + CONF_ZONE_TYPE: BinarySensorDeviceClass.PROBLEM, + }, + MOCK_OUTPUT_SUBENTRY, + ), + ( + {CONF_NAME: "Gate Lock"}, + MOCK_SWITCHABLE_OUTPUT_SUBENTRY, + ), + ], +) +async def test_subentry_reconfigure( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, + user_input: dict[str, Any], + subentry: ConfigSubentry, +) -> None: + """Test subentry reconfiguration.""" + + mock_config_entry_with_subentries.add_to_hass(hass) + + assert await hass.config_entries.async_setup( + mock_config_entry_with_subentries.entry_id + ) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + ( + mock_config_entry_with_subentries.entry_id, + subentry.subentry_type, + ), + context={ + "source": SOURCE_RECONFIGURE, + "subentry_id": subentry.subentry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert len(mock_config_entry_with_subentries.subentries) == 4 + + subentry_result = { + **subentry.as_dict(), + "data": {**subentry.data, **user_input}, + "title": user_input.get(CONF_NAME), + } + + assert mock_config_entry_with_subentries.subentries.get( + subentry.subentry_id + ) == ConfigSubentry(**subentry_result) + + +@pytest.mark.parametrize( + ("subentry", "error_field"), + [ + (MOCK_PARTITION_SUBENTRY, CONF_PARTITION_NUMBER), + (MOCK_ZONE_SUBENTRY, CONF_ZONE_NUMBER), + (MOCK_OUTPUT_SUBENTRY, CONF_OUTPUT_NUMBER), + (MOCK_SWITCHABLE_OUTPUT_SUBENTRY, CONF_SWITCHABLE_OUTPUT_NUMBER), + ], +) +async def test_cannot_create_same_subentry( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, + subentry: dict[str, Any], + error_field: str, +) -> None: + """Test subentry reconfiguration.""" + mock_config_entry_with_subentries.add_to_hass(hass) + + assert await hass.config_entries.async_setup( + mock_config_entry_with_subentries.entry_id + ) + await hass.async_block_till_done() + + mock_setup_entry.reset_mock() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry_with_subentries.entry_id, subentry.subentry_type), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {**subentry.data} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {error_field: "already_configured"} + assert len(mock_config_entry_with_subentries.subentries) == 4 + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_one_config_allowed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that only one Satel Integra configuration is allowed.""" + 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.ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/satel_integra/test_diagnostics.py b/tests/components/satel_integra/test_diagnostics.py new file mode 100644 index 00000000000..93afd530e65 --- /dev/null +++ b/tests/components/satel_integra/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Tests for satel integra diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + mock_config_entry_with_subentries: MockConfigEntry, + mock_satel: AsyncMock, +) -> None: + """Test diagnostics for config entry.""" + mock_config_entry_with_subentries.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_with_subentries.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry_with_subentries + ) + assert diagnostics == snapshot(exclude=props("created_at", "modified_at", "id")) diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index 363e30b9269..088ecc182ee 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -2,8 +2,10 @@ from __future__ import annotations +from http import HTTPStatus from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -16,6 +18,7 @@ from homeassistant.util import dt as dt_util from . import MockRestData, return_integration_config from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -152,3 +155,41 @@ async def test_device_remove_devices( ) response = await client.remove_device(dead_device_entry.id, loaded_entry.entry_id) assert response["success"] + + +async def test_resource_template( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test resource_template is evaluated on each scan.""" + hass.states.async_set("sensor.input_sensor", "localhost") + aioclient_mock.get( + "http://localhost", + status=HTTPStatus.OK, + text="

First

", + ) + aioclient_mock.get( + "http://localhost2", + status=HTTPStatus.OK, + text="

Second

", + ) + + config = { + DOMAIN: { + "resource_template": "http://{{ states.sensor.input_sensor.state }}", + "verify_ssl": True, + "sensor": [{"select": "h1", "name": "template sensor"}], + } + } + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.template_sensor") + assert state.state == "First" + + hass.states.async_set("sensor.input_sensor", "localhost2") + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.template_sensor") + assert state.state == "Second" diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 3b0bff7e82e..908ec222e13 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -654,14 +654,14 @@ async def test_shared_context(hass: HomeAssistant) -> None: assert event_mock.call_count == 1 assert run_mock.call_count == 1 - args, kwargs = run_mock.call_args + args, _kwargs = run_mock.call_args assert args[0].context == context # Ensure event data has all attributes set assert args[0].data.get(ATTR_NAME) == "test" assert args[0].data.get(ATTR_ENTITY_ID) == "script.test" # Ensure context carries through the event - args, kwargs = event_mock.call_args + args, _kwargs = event_mock.call_args assert args[0].context == context # Ensure the script state shares the same context diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 7e848f3870c..e2216f0c8ef 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -25,7 +25,9 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.sensibo.climate import ( +from homeassistant.components.sensibo.climate import _find_valid_target_temp +from homeassistant.components.sensibo.const import DOMAIN +from homeassistant.components.sensibo.services import ( ATTR_AC_INTEGRATION, ATTR_GEO_INTEGRATION, ATTR_HIGH_TEMPERATURE_STATE, @@ -46,9 +48,7 @@ from homeassistant.components.sensibo.climate import ( SERVICE_ENABLE_TIMER, SERVICE_FULL_STATE, SERVICE_GET_DEVICE_CAPABILITIES, - _find_valid_target_temp, ) -from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 1b9810a8250..ea5f6db0bf6 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -80,6 +80,7 @@ UNITS_OF_MEASUREMENT = { SensorDeviceClass.PM10: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SensorDeviceClass.PM1: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SensorDeviceClass.PM25: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SensorDeviceClass.PM4: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SensorDeviceClass.POWER: UnitOfPower.KILO_WATT, SensorDeviceClass.POWER_FACTOR: PERCENTAGE, SensorDeviceClass.PRECIPITATION: UnitOfPrecipitationDepth.MILLIMETERS, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 88bec54c936..a0e97ac9e0d 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -125,7 +125,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert len(conditions) == 55 + assert len(conditions) == 56 assert conditions == unordered(expected_conditions) diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 31bd0d2be55..1034f3473db 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -126,7 +126,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 55 + assert len(triggers) == 56 assert triggers == unordered(expected_triggers) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index ce78edfe481..60eda1b9d64 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -8,6 +8,7 @@ from decimal import Decimal from typing import Any from unittest.mock import patch +from freezegun.api import freeze_time import pytest from homeassistant.components import sensor @@ -32,6 +33,7 @@ from homeassistant.components.sensor.const import STATE_CLASS_UNITS, UNIT_CONVER from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, STATE_UNKNOWN, EntityCategory, @@ -474,6 +476,62 @@ async def test_restore_sensor_save_state( assert type(extra_data["native_value"]) is native_value_type +@freeze_time("2020-02-08 15:00:00") +async def test_restore_sensor_save_state_frozen_time_datetime( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test RestoreSensor.""" + entity0 = MockRestoreSensor( + name="Test", + native_value=dt_util.utcnow(), + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Trigger saving state + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity0.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == RESTORE_DATA["datetime"] + assert type(extra_data["native_value"]) is dict + + +@freeze_time("2020-02-08 15:00:00") +async def test_restore_sensor_save_state_frozen_time_date( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test RestoreSensor.""" + entity0 = MockRestoreSensor( + name="Test", + native_value=dt_util.utcnow().date(), + native_unit_of_measurement=None, + device_class=SensorDeviceClass.DATE, + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Trigger saving state + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity0.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == RESTORE_DATA["date"] + assert type(extra_data["native_value"]) is dict + + @pytest.mark.parametrize( ("native_value", "native_value_type", "extra_data", "device_class", "uom"), [ @@ -2157,6 +2215,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.PM1, SensorDeviceClass.PM10, SensorDeviceClass.PM25, + SensorDeviceClass.PM4, SensorDeviceClass.POWER_FACTOR, SensorDeviceClass.POWER, SensorDeviceClass.PRECIPITATION_INTENSITY, @@ -2938,6 +2997,13 @@ async def test_suggested_unit_guard_invalid_unit( UnitOfDataRate.BITS_PER_SECOND, 10000, ), + ( + SensorDeviceClass.CO2, + CONCENTRATION_PARTS_PER_MILLION, + 10, + CONCENTRATION_PARTS_PER_MILLION, + 10, + ), ], ) async def test_suggested_unit_guard_valid_unit( @@ -3017,6 +3083,7 @@ def test_device_class_converters_are_complete() -> None: SensorDeviceClass.PM1, SensorDeviceClass.PM10, SensorDeviceClass.PM25, + SensorDeviceClass.PM4, SensorDeviceClass.SIGNAL_STRENGTH, SensorDeviceClass.SOUND_PRESSURE, SensorDeviceClass.SULPHUR_DIOXIDE, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 8b6d55cb9a9..6afce0d3eb5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -65,6 +65,8 @@ from tests.components.recorder.common import ( assert_multiple_states_equal_without_context_and_last_changed, async_recorder_block_till_done, async_wait_recording_done, + db_state_attributes_to_native, + db_state_to_native, do_adhoc_statistics, get_start_time, statistics_during_period, @@ -1967,7 +1969,7 @@ async def test_compile_hourly_sum_statistics_total_no_reset( } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] with freeze_time(period0) as freezer: - four, eight, states = await async_record_meter_states( + _four, eight, states = await async_record_meter_states( hass, freezer, period0, "sensor.test1", attributes, seq ) await async_wait_recording_done(hass) @@ -2081,7 +2083,7 @@ async def test_compile_hourly_sum_statistics_total_increasing( } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] with freeze_time(period0) as freezer: - four, eight, states = await async_record_meter_states( + _four, eight, states = await async_record_meter_states( hass, freezer, period0, "sensor.test1", attributes, seq ) await async_wait_recording_done(hass) @@ -2195,7 +2197,7 @@ async def test_compile_hourly_sum_statistics_total_increasing_small_dip( } seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] with freeze_time(period0) as freezer: - four, eight, states = await async_record_meter_states( + _four, eight, states = await async_record_meter_states( hass, freezer, period0, "sensor.test1", attributes, seq ) await async_wait_recording_done(hass) @@ -4941,9 +4943,15 @@ async def async_record_states( POWER_SENSOR_ATTRIBUTES, "W", "kW", - "GW, MW, TW, W, kW, mW", + "BTU/h, GW, MW, TW, W, kW, mW", + ), + ( + METRIC_SYSTEM, + POWER_SENSOR_ATTRIBUTES, + "W", + "kW", + "BTU/h, GW, MW, TW, W, kW, mW", ), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW, mW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, @@ -4957,14 +4965,14 @@ async def async_record_states( PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ], ) @@ -5159,9 +5167,15 @@ async def test_validate_statistics_unit_ignore_device_class( POWER_SENSOR_ATTRIBUTES, "W", "kW", - "GW, MW, TW, W, kW, mW", + "BTU/h, GW, MW, TW, W, kW, mW", + ), + ( + METRIC_SYSTEM, + POWER_SENSOR_ATTRIBUTES, + "W", + "kW", + "BTU/h, GW, MW, TW, W, kW, mW", ), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW, mW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, @@ -5175,21 +5189,21 @@ async def test_validate_statistics_unit_ignore_device_class( PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, BATTERY_SENSOR_ATTRIBUTES, "%", None, - "%, ", + "%, , ppb, ppm", ), ], ) @@ -5824,7 +5838,7 @@ async def test_validate_statistics_unit_change_equivalent_units( @pytest.mark.parametrize( ("attributes", "unit1", "unit2", "supported_unit"), [ - (NONE_SENSOR_ATTRIBUTES, "m³", "m3", "CCF, L, fl. oz., ft³, gal, mL, m³"), + (NONE_SENSOR_ATTRIBUTES, "m³", "m3", "CCF, L, MCF, fl. oz., ft³, gal, mL, m³"), (NONE_SENSOR_ATTRIBUTES, "\u03bcV", "\u00b5V", "MV, V, kV, mV, \u03bcV"), (NONE_SENSOR_ATTRIBUTES, "\u03bcS/cm", "\u00b5S/cm", "S/cm, mS/cm, \u03bcS/cm"), ( @@ -6153,8 +6167,8 @@ async def test_exclude_attributes(hass: HomeAssistant) -> None: .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): db_state.entity_id = db_states_meta.entity_id - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() + state = db_state_to_native(db_state) + state.attributes = db_state_attributes_to_native(db_state_attributes) native_states.append(state) return native_states diff --git a/tests/components/sensor/test_websocket_api.py b/tests/components/sensor/test_websocket_api.py index b1dafa04c94..f0bb8f6c71f 100644 --- a/tests/components/sensor/test_websocket_api.py +++ b/tests/components/sensor/test_websocket_api.py @@ -39,6 +39,7 @@ async def test_device_class_units( "in/s", "km/h", "kn", + "m/min", "m/s", "mm/d", "mm/h", diff --git a/tests/components/sftp_storage/__init__.py b/tests/components/sftp_storage/__init__.py new file mode 100644 index 00000000000..c1739571bce --- /dev/null +++ b/tests/components/sftp_storage/__init__.py @@ -0,0 +1 @@ +"""Tests SFTP Storage integration.""" diff --git a/tests/components/sftp_storage/asyncssh_mock.py b/tests/components/sftp_storage/asyncssh_mock.py new file mode 100644 index 00000000000..829ca44d4c2 --- /dev/null +++ b/tests/components/sftp_storage/asyncssh_mock.py @@ -0,0 +1,139 @@ +"""Mock classes for asyncssh module.""" + +from __future__ import annotations + +import json +from typing import Self +from unittest.mock import AsyncMock + +from asyncssh.misc import async_context_manager + + +class SSHClientConnectionMock: + """Class that mocks SSH Client connection.""" + + def __init__(self, *args, **kwargs) -> None: + """Initialize SSHClientConnectionMock.""" + self._sftp: SFTPClientMock = SFTPClientMock() + + async def __aenter__(self) -> Self: + """Allow SSHClientConnectionMock to be used as an async context manager.""" + return self + + async def __aexit__(self, *args) -> None: + """Allow SSHClientConnectionMock to be used as an async context manager.""" + self.close() + + def close(self): + """Mock `close` from `SSHClientConnection`.""" + return + + def mock_setup_backup(self, metadata: dict, with_bad: bool = False) -> str: + """Setup mocks to properly return a backup. + + Return: Backup ID (slug) + """ + + slug = metadata["metadata"]["backup_id"] + side_effect = [ + json.dumps(metadata), # from async_list_backups + json.dumps(metadata), # from iter_file -> _load_metadata + b"backup data", # from AsyncFileIterator + b"", + ] + self._sftp._mock_listdir.return_value = [f"{slug}.metadata.json"] + + if with_bad: + side_effect.insert(0, "invalid") + self._sftp._mock_listdir.return_value = [ + "invalid.metadata.json", + f"{slug}.metadata.json", + ] + + self._sftp._mock_open._mock_read.side_effect = side_effect + return slug + + @async_context_manager + async def start_sftp_client(self, *args, **kwargs) -> SFTPClientMock: + """Return mocked SFTP Client.""" + return self._sftp + + async def wait_closed(self): + """Mock `wait_closed` from `SFTPClient`.""" + return + + +class SFTPClientMock: + """Class that mocks SFTP Client connection.""" + + def __init__(self, *args, **kwargs) -> None: + """Initialize `SFTPClientMock`.""" + self._mock_chdir = AsyncMock() + self._mock_listdir = AsyncMock() + self._mock_exists = AsyncMock(return_value=True) + self._mock_unlink = AsyncMock() + self._mock_open = SFTPOpenMock() + + async def __aenter__(self) -> Self: + """Allow SFTPClientMock to be used as an async context manager.""" + return self + + async def __aexit__(self, *args) -> None: + """Allow SFTPClientMock to be used as an async context manager.""" + self.exit() + + async def chdir(self, *args) -> None: + """Mock `chdir` method from SFTPClient.""" + await self._mock_chdir(*args) + + async def listdir(self, *args) -> list[str]: + """Mock `listdir` method from SFTPClient.""" + result = await self._mock_listdir(*args) + return result if result is not None else [] + + @async_context_manager + async def open(self, *args, **kwargs) -> SFTPOpenMock: + """Mock open a remote file.""" + return self._mock_open + + async def exists(self, *args) -> bool: + """Mock `exists` method from SFTPClient.""" + return await self._mock_exists(*args) + + async def unlink(self, *args) -> None: + """Mock `unlink` method from SFTPClient.""" + await self._mock_unlink(*args) + + def exit(self): + """Mandatory method for quitting SFTP Client.""" + return + + async def wait_closed(self): + """Mock `wait_closed` from `SFTPClient`.""" + return + + +class SFTPOpenMock: + """Mocked remote file.""" + + def __init__(self) -> None: + """Initialize arguments for mocked responses.""" + self._mock_read = AsyncMock(return_value=b"") + self._mock_write = AsyncMock() + self.close = AsyncMock(return_value=None) + + async def __aenter__(self): + """Allow SFTPOpenMock to be used as an async context manager.""" + return self + + async def __aexit__(self, *args) -> None: + """Allow SFTPOpenMock to be used as an async context manager.""" + + async def read(self, *args, **kwargs) -> bytes: + """Read remote file - mocked response from `self._mock_read`.""" + return await self._mock_read(*args, **kwargs) + + async def write(self, content, *args, **kwargs) -> int: + """Mock write to remote file.""" + await self._mock_write(content, *args, **kwargs) + return len(content) diff --git a/tests/components/sftp_storage/conftest.py b/tests/components/sftp_storage/conftest.py new file mode 100644 index 00000000000..0a5a4b484a5 --- /dev/null +++ b/tests/components/sftp_storage/conftest.py @@ -0,0 +1,155 @@ +"""PyTest fixtures and test helpers.""" + +from collections.abc import Awaitable, Callable, Generator +from contextlib import contextmanager, suppress +from pathlib import Path +from unittest.mock import patch + +from asyncssh import generate_private_key +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.sftp_storage import SFTPConfigEntryData +from homeassistant.components.sftp_storage.const import ( + CONF_BACKUP_LOCATION, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PRIVATE_KEY_FILE, + CONF_USERNAME, + DEFAULT_PKEY_NAME, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.setup import async_setup_component +from homeassistant.util.ulid import ulid + +from .asyncssh_mock import SSHClientConnectionMock, async_context_manager + +from tests.common import MockConfigEntry + +type ComponentSetup = Callable[[], Awaitable[None]] + +BACKUP_METADATA = { + "file_path": "backup_location/backup.tar", + "metadata": { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "backup_id": "test-backup", + "date": "2025-01-01T01:23:45.687000+01:00", + "database_included": True, + "extra_metadata": { + "instance_id": 1, + "with_automatic_settings": False, + "supervisor.backup_request_date": "2025-01-01T01:23:45.687000+01:00", + }, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": True, + "size": 1234, + }, +} +TEST_AGENT_BACKUP = AgentBackup.from_dict(BACKUP_METADATA["metadata"]) + +CONFIG_ENTRY_TITLE = "testsshuser@127.0.0.1" +PRIVATE_KEY_FILE_UUID = "0123456789abcdef0123456789abcdef" +USER_INPUT = { + CONF_HOST: "127.0.0.1", + CONF_PORT: 22, + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PRIVATE_KEY_FILE: PRIVATE_KEY_FILE_UUID, + CONF_BACKUP_LOCATION: "backup_location", +} +TEST_AGENT_ID = ulid() + + +@contextmanager +def private_key_file(hass: HomeAssistant) -> Generator[str]: + """Fixture that create private key file in integration storage directory.""" + + # Create private key file and parent directory. + key_dest_path = Path(hass.config.path(STORAGE_DIR, DOMAIN)) + dest_file = key_dest_path / f".{ulid()}_{DEFAULT_PKEY_NAME}" + dest_file.parent.mkdir(parents=True, exist_ok=True) + + # Write to file only once. + if not dest_file.exists(): + dest_file.write_bytes( + generate_private_key("ssh-rsa").export_private_key("pkcs8-pem") + ) + + yield str(dest_file) + + if dest_file.exists(): + dest_file.unlink(missing_ok=True) + with suppress(OSError): + dest_file.parent.rmdir() + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_ssh_connection: SSHClientConnectionMock, +) -> ComponentSetup: + """Fixture for setting up the component manually.""" + config_entry.add_to_hass(hass) + + async def func(config_entry: MockConfigEntry = config_entry) -> None: + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await hass.config_entries.async_setup(config_entry.entry_id) + + return func + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> Generator[MockConfigEntry]: + """Fixture for MockConfigEntry.""" + + # pylint: disable-next=contextmanager-generator-missing-cleanup + with private_key_file(hass) as private_key: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id=TEST_AGENT_ID, + unique_id=TEST_AGENT_ID, + title=CONFIG_ENTRY_TITLE, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 22, + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PRIVATE_KEY_FILE: str(private_key), + CONF_BACKUP_LOCATION: "backup_location", + }, + ) + + config_entry.runtime_data = SFTPConfigEntryData(**config_entry.data) + yield config_entry + + +@pytest.fixture +def mock_ssh_connection(): + """Mock `SSHClientConnection` globally.""" + mock = SSHClientConnectionMock() + + # We decorate from same decorator from asyncssh + # It makes the callable an awaitable and context manager. + @async_context_manager + async def mock_connect(*args, **kwargs): + """Mock the asyncssh.connect function to return our mock directly.""" + return mock + + with ( + patch( + "homeassistant.components.sftp_storage.client.connect", + side_effect=mock_connect, + ), + patch( + "homeassistant.components.sftp_storage.config_flow.connect", + side_effect=mock_connect, + ), + ): + yield mock diff --git a/tests/components/sftp_storage/test_backup.py b/tests/components/sftp_storage/test_backup.py new file mode 100644 index 00000000000..52cdcd49df1 --- /dev/null +++ b/tests/components/sftp_storage/test_backup.py @@ -0,0 +1,418 @@ +"""Test the Backup SFTP Location platform.""" + +from io import StringIO +import json +from typing import Any +from unittest.mock import MagicMock, patch + +from asyncssh.sftp import SFTPError +import pytest + +from homeassistant.components.sftp_storage.backup import ( + async_register_backup_agents_listener, +) +from homeassistant.components.sftp_storage.const import ( + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .asyncssh_mock import SSHClientConnectionMock +from .conftest import ( + BACKUP_METADATA, + CONFIG_ENTRY_TITLE, + TEST_AGENT_BACKUP, + TEST_AGENT_ID, + ComponentSetup, +) + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def mock_setup_integration( + setup_integration: ComponentSetup, +) -> None: + """Set up the integration automatically for backup tests.""" + await setup_integration() + + +def generate_result(metadata: dict) -> dict: + """Generates an expected result from metadata.""" + + expected_result: dict = metadata["metadata"].copy() + expected_result["agents"] = { + f"{DOMAIN}.{TEST_AGENT_ID}": { + "protected": expected_result.pop("protected"), + "size": expected_result.pop("size"), + } + } + expected_result.update( + { + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], + "with_automatic_settings": None, + } + ) + return expected_result + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": f"{DOMAIN}.{TEST_AGENT_ID}", "name": CONFIG_ENTRY_TITLE}, + ], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(config_entry.entry_id) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert ( + response["result"] + == {"agents": [{"agent_id": "backup.local", "name": "local"}]} + or config_entry.state == ConfigEntryState.NOT_LOADED + ) + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent list backups.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + expected_result = generate_result(BACKUP_METADATA) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [expected_result] + + +async def test_agents_list_backups_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent list backups fails.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + mock_ssh_connection._sftp._mock_open._mock_read.side_effect = SFTPError( + 2, "Error message" + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{TEST_AGENT_ID}": "Remote server error while attempting to list backups: Error message" + } + + +async def test_agents_list_backups_include_bad_metadata( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_ssh_connection: SSHClientConnectionMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent list backups.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA, with_bad=True) + expected_result = generate_result(BACKUP_METADATA) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [expected_result] + # Called two times, one for bad backup metadata and once for good + assert mock_ssh_connection._sftp._mock_open._mock_read.call_count == 2 + assert ( + "Failed to load backup metadata from file: backup_location/invalid.metadata.json. Expecting value: line 1 column 1 (char 0)" + in caplog.messages + ) + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + (TEST_AGENT_BACKUP.backup_id, generate_result(BACKUP_METADATA)), + ("12345", None), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + backup_id: str, + expected_result: dict[str, Any] | None, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent get backup.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] == expected_result + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent download backup.""" + client = await hass_client() + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={DOMAIN}.{TEST_AGENT_ID}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_ssh_connection._sftp._mock_open.close.assert_awaited() + + +async def test_agents_download_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent download backup fails.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + + # This will cause `FileNotFoundError` exception in `BackupAgentClient.iter_file() method.` + mock_ssh_connection._sftp._mock_exists.side_effect = [True, False] + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={DOMAIN}.{TEST_AGENT_ID}" + ) + assert resp.status == 404 + + # This will raise `RuntimeError` causing Internal Server Error, mimicking that the SFTP setup failed. + mock_ssh_connection._sftp = None + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={DOMAIN}.{TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert b"Internal Server Error" in content + + +async def test_agents_download_metadata_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + + mock_ssh_connection._sftp._mock_exists.return_value = False + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={DOMAIN}.{TEST_AGENT_ID}" + ) + assert resp.status == 404 + content = await resp.content.read() + assert content.decode() == "" + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + ): + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert ( + f"Successfully uploaded backup id: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + ) + # Called write 2 times + # 1. When writing backup file + # 2. When writing metadata file + assert mock_ssh_connection._sftp._mock_open._mock_write.call_count == 2 + + # This is 'backup file' + assert ( + b"test" + in mock_ssh_connection._sftp._mock_open._mock_write.call_args_list[0].args + ) + + # This is backup metadata + uploaded_metadata = json.loads( + mock_ssh_connection._sftp._mock_open._mock_write.call_args_list[1].args[0] + )["metadata"] + assert uploaded_metadata == BACKUP_METADATA["metadata"] + + +async def test_agents_upload_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent upload backup fails.""" + client = await hass_client() + mock_ssh_connection._sftp._mock_open._mock_write.side_effect = SFTPError( + 2, "Error message" + ) + + with ( + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + ): + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert ( + f"Unexpected error for {DOMAIN}.{TEST_AGENT_ID}: Error message" + in caplog.messages + ) + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent delete backup.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + # Called 2 times, to remove metadata and backup file. + assert mock_ssh_connection._sftp._mock_unlink.call_count == 2 + + +@pytest.mark.parametrize( + ("exists_side_effect", "expected_result"), + [ + ( + [True, False], + {"agent_errors": {}}, + ), # First `True` is to confirm the metadata file exists + ( + SFTPError(0, "manual"), + { + "agent_errors": { + f"{DOMAIN}.{TEST_AGENT_ID}": f"Failed to delete backup id: {TEST_AGENT_BACKUP.backup_id}: manual" + } + }, + ), + ], + ids=["file_not_found_exc", "sftp_error_exc"], +) +async def test_agents_delete_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_ssh_connection: SSHClientConnectionMock, + exists_side_effect: bool | Exception, + expected_result: dict[str, dict[str, str]], +) -> None: + """Test agent delete backup fails.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + mock_ssh_connection._sftp._mock_exists.side_effect = exists_side_effect + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == expected_result + + +async def test_agents_delete_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test agent delete backup not found.""" + mock_ssh_connection.mock_setup_backup(BACKUP_METADATA) + + client = await hass_ws_client(hass) + backup_id = "1234" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [ + listener + ] # make sure it's the last listener + remove_listener() + + assert DATA_BACKUP_AGENT_LISTENERS not in hass.data diff --git a/tests/components/sftp_storage/test_config_flow.py b/tests/components/sftp_storage/test_config_flow.py new file mode 100644 index 00000000000..3974b5aaa6c --- /dev/null +++ b/tests/components/sftp_storage/test_config_flow.py @@ -0,0 +1,192 @@ +"""Tests config_flow.""" + +from collections.abc import Awaitable, Callable +from tempfile import NamedTemporaryFile +from unittest.mock import patch + +from asyncssh import KeyImportError, generate_private_key +from asyncssh.misc import PermissionDenied +from asyncssh.sftp import SFTPNoSuchFile, SFTPPermissionDenied +import pytest + +from homeassistant.components.sftp_storage.config_flow import ( + SFTPStorageInvalidPrivateKey, + SFTPStorageMissingPasswordOrPkey, +) +from homeassistant.components.sftp_storage.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PRIVATE_KEY_FILE, + CONF_USERNAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import USER_INPUT, SSHClientConnectionMock + +from tests.common import MockConfigEntry + +type ComponentSetup = Callable[[], Awaitable[None]] + + +@pytest.fixture +def mock_process_uploaded_file(): + """Mocks ability to process uploaded private key.""" + with ( + patch( + "homeassistant.components.sftp_storage.config_flow.process_uploaded_file" + ) as mock_process_uploaded_file, + patch("shutil.move") as mock_shutil_move, + NamedTemporaryFile() as f, + ): + pkey = generate_private_key("ssh-rsa") + f.write(pkey.export_private_key("pkcs8-pem")) + f.flush() + mock_process_uploaded_file.return_value.__enter__.return_value = f.name + mock_shutil_move.return_value = f.name + yield + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("mock_process_uploaded_file") +@pytest.mark.usefixtures("mock_ssh_connection") +async def test_backup_sftp_full_flow( + hass: HomeAssistant, +) -> None: + """Test the full backup_sftp config flow with valid user input.""" + + user_input = USER_INPUT.copy() + # Start the configuration flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + # The first step should be the "user" form. + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + # Verify that a new config entry is created. + assert result["type"] is FlowResultType.CREATE_ENTRY + expected_title = f"{user_input[CONF_USERNAME]}@{user_input[CONF_HOST]}" + assert result["title"] == expected_title + + # Make sure to match the `private_key_file` from entry + user_input[CONF_PRIVATE_KEY_FILE] = result["data"][CONF_PRIVATE_KEY_FILE] + + assert result["data"] == user_input + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("mock_process_uploaded_file") +@pytest.mark.usefixtures("mock_ssh_connection") +async def test_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test successful failure of already added config entry.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception_type", "error_base"), + [ + (OSError, "os_error"), + (SFTPStorageInvalidPrivateKey, "invalid_key"), + (PermissionDenied, "permission_denied"), + (SFTPStorageMissingPasswordOrPkey, "key_or_password_needed"), + (SFTPNoSuchFile, "sftp_no_such_file"), + (SFTPPermissionDenied, "sftp_permission_denied"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("mock_process_uploaded_file") +async def test_config_flow_exceptions( + exception_type: Exception, + error_base: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_ssh_connection: SSHClientConnectionMock, +) -> None: + """Test successful failure of already added config entry.""" + + mock_ssh_connection._sftp._mock_chdir.side_effect = exception_type("Error message.") + + # config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] and result["errors"]["base"] == error_base + + # Recover from the error + mock_ssh_connection._sftp._mock_chdir.side_effect = None + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("mock_process_uploaded_file") +async def test_config_entry_error(hass: HomeAssistant) -> None: + """Test config flow with raised `KeyImportError`.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + + with ( + patch( + "homeassistant.components.sftp_storage.config_flow.SSHClientConnectionOptions", + side_effect=KeyImportError("Invalid key"), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert "errors" in result and result["errors"]["base"] == "invalid_key" + + user_input = USER_INPUT.copy() + user_input[CONF_PASSWORD] = "" + del user_input[CONF_PRIVATE_KEY_FILE] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert "errors" in result and result["errors"]["base"] == "key_or_password_needed" diff --git a/tests/components/sftp_storage/test_init.py b/tests/components/sftp_storage/test_init.py new file mode 100644 index 00000000000..7f366facb65 --- /dev/null +++ b/tests/components/sftp_storage/test_init.py @@ -0,0 +1,193 @@ +"""Tests for SFTP Storage.""" + +from pathlib import Path +from unittest.mock import patch + +from asyncssh.sftp import SFTPPermissionDenied +import pytest + +from homeassistant.components.sftp_storage import SFTPConfigEntryData +from homeassistant.components.sftp_storage.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.util.ulid import ulid + +from .asyncssh_mock import SSHClientConnectionMock +from .conftest import ( + CONF_BACKUP_LOCATION, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PRIVATE_KEY_FILE, + CONF_USERNAME, + USER_INPUT, + ComponentSetup, + private_key_file, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_ssh_connection") +async def test_setup_and_unload( + hass: HomeAssistant, + setup_integration: ComponentSetup, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test successful setup and unload.""" + + # Patch the `exists` function of Path so that we can also + # test the `homeassistant.components.sftp_storage.client.get_client_keys()` function + with ( + patch( + "homeassistant.components.sftp_storage.client.SSHClientConnectionOptions" + ), + patch("pathlib.Path.exists", return_value=True), + ): + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + + assert entries[0].state is ConfigEntryState.NOT_LOADED + assert ( + f"Unloading {DOMAIN} integration for host {entries[0].data[CONF_USERNAME]}@{entries[0].data[CONF_HOST]}" + in caplog.messages + ) + + +async def test_setup_error( + mock_ssh_connection: SSHClientConnectionMock, + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test setup error.""" + mock_ssh_connection._sftp._mock_chdir.side_effect = SFTPPermissionDenied( + "Error message" + ) + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_unexpected_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup error.""" + with patch( + "homeassistant.components.sftp_storage.client.connect", + side_effect=OSError("Error message"), + ): + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_ERROR + assert ( + "Failure while attempting to establish SSH connection. Please check SSH credentials and if changed, re-install the integration" + in caplog.text + ) + + +async def test_async_remove_entry( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test async_remove_entry.""" + # Setup default config entry + await setup_integration() + + # Setup additional config entry + agent_id = ulid() + with private_key_file(hass) as private_key: + new_config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id=agent_id, + unique_id=agent_id, + title="another@192.168.0.100", + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 22, + CONF_USERNAME: "another", + CONF_PASSWORD: "password", + CONF_PRIVATE_KEY_FILE: str(private_key), + CONF_BACKUP_LOCATION: "backup_location", + }, + ) + new_config_entry.add_to_hass(hass) + await setup_integration(new_config_entry) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + config_entry = entries[0] + private_key = Path(config_entry.data[CONF_PRIVATE_KEY_FILE]) + new_private_key = Path(new_config_entry.data[CONF_PRIVATE_KEY_FILE]) + + # Make sure private keys from both configs exists + assert private_key.parent == new_private_key.parent + assert private_key.exists() + assert new_private_key.exists() + + # Remove first config entry - the private key from second will still be in filesystem + # as well as integration storage directory + assert await hass.config_entries.async_remove(config_entry.entry_id) + assert not private_key.exists() + assert new_private_key.exists() + assert new_private_key.parent.exists() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + # Remove the second config entry, ensuring all files and integration storage directory removed. + assert await hass.config_entries.async_remove(new_config_entry.entry_id) + assert not new_private_key.exists() + assert not new_private_key.parent.exists() + + assert hass.config_entries.async_entries(DOMAIN) == [] + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("patch_target", "expected_logs"), + [ + ( + "os.unlink", + [ + "Failed to remove private key", + f"Storage directory for {DOMAIN} integration is not empty", + ], + ), + ("os.rmdir", ["Error occurred while removing directory"]), + ], +) +async def test_async_remove_entry_errors( + patch_target: str, + expected_logs: list[str], + hass: HomeAssistant, + setup_integration: ComponentSetup, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_remove_entry.""" + # Setup default config entry + await setup_integration() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + config_entry = entries[0] + + with patch(patch_target, side_effect=OSError(13, "Permission denied")): + await hass.config_entries.async_remove(config_entry.entry_id) + for logline in expected_logs: + assert logline in caplog.text + + +async def test_config_entry_data_password_hidden() -> None: + """Test hiding password in `SFTPConfigEntryData` string representation.""" + user_input = USER_INPUT.copy() + entry_data = SFTPConfigEntryData(**user_input) + assert "password=" not in str(entry_data) diff --git a/tests/components/sharkiq/test_config_flow.py b/tests/components/sharkiq/test_config_flow.py index 22a77678c0d..f96b2f31e0b 100644 --- a/tests/components/sharkiq/test_config_flow.py +++ b/tests/components/sharkiq/test_config_flow.py @@ -47,6 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch("sharkiq.AylaApi.async_sign_in", return_value=True), + patch("sharkiq.AylaApi.async_set_cookie"), patch( "homeassistant.components.sharkiq.async_setup_entry", return_value=True, @@ -84,7 +85,10 @@ async def test_form_error(hass: HomeAssistant, exc: Exception, base_error: str) DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch.object(AylaApi, "async_sign_in", side_effect=exc): + with ( + patch.object(AylaApi, "async_sign_in", side_effect=exc), + patch("sharkiq.AylaApi.async_set_cookie"), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, @@ -101,7 +105,10 @@ async def test_reauth_success(hass: HomeAssistant) -> None: result = await mock_config.start_reauth_flow(hass) - with patch("sharkiq.AylaApi.async_sign_in", return_value=True): + with ( + patch("sharkiq.AylaApi.async_sign_in", return_value=True), + patch("sharkiq.AylaApi.async_set_cookie"), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) @@ -132,7 +139,10 @@ async def test_reauth( result = await mock_config.start_reauth_flow(hass) - with patch("sharkiq.AylaApi.async_sign_in", side_effect=side_effect): + with ( + patch("sharkiq.AylaApi.async_sign_in", side_effect=side_effect), + patch("sharkiq.AylaApi.async_set_cookie"), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index bfb2176026b..5b5339ec7a2 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -80,6 +80,9 @@ class MockAyla(AylaApi): async def async_sign_in(self): """Instead of signing in, just return.""" + async def async_set_cookie(self): + """Instead of getting cookies, just return.""" + async def async_refresh_auth(self): """Instead of refreshing auth, just return.""" diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index a333e55560f..8eaffe5af86 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -1,16 +1,23 @@ """Tests for the Shelly integration.""" -from collections.abc import Mapping +from collections.abc import Mapping, Sequence +from contextlib import contextmanager from copy import deepcopy from datetime import timedelta from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch from aioshelly.const import MODEL_25 from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props +from homeassistant.components.shelly import ( + BLOCK_SLEEPING_PLATFORMS, + PLATFORMS, + RPC_SLEEPING_PLATFORMS, +) from homeassistant.components.shelly.const import ( CONF_GEN, CONF_SLEEP_PERIOD, @@ -19,14 +26,13 @@ from homeassistant.components.shelly.const import ( RPC_SENSORS_POLLING_INTERVAL, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.const import CONF_HOST, CONF_MODEL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceEntry, DeviceRegistry, - format_mac, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -100,10 +106,12 @@ async def mock_rest_update( async def mock_polling_rpc_update( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + seconds: float = RPC_SENSORS_POLLING_INTERVAL, ) -> None: """Move time to create polling RPC sensors update event.""" - freezer.tick(timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL)) + freezer.tick(timedelta(seconds=seconds)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -150,7 +158,18 @@ def register_device( """Register Shelly device.""" return device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, + connections={(CONNECTION_NETWORK_MAC, MOCK_MAC)}, + ) + + +def register_sub_device( + device_registry: DeviceRegistry, config_entry: ConfigEntry, unique_id: str +) -> DeviceEntry: + """Register Shelly sub-device.""" + return device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{MOCK_MAC}-{unique_id}")}, + via_device=(DOMAIN, MOCK_MAC), ) @@ -161,15 +180,27 @@ async def snapshot_device_entities( config_entry_id: str, ) -> None: """Snapshot all device entities.""" + + def sort_event_types(data: Any, path: Sequence[tuple[str, Any]]) -> Any: + """Sort the event_types list for event entity.""" + if path and path[-1][0] == "event_types" and isinstance(data, list): + return sorted(data) + + return data + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries for entity_entry in entity_entries: - assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_entry == snapshot( + name=f"{entity_entry.entity_id}-entry", exclude=props("event_types") + ) assert entity_entry.disabled_by is None, "Please enable all entities." state = hass.states.get(entity_entry.entity_id) assert state, f"State not found for {entity_entry.entity_id}" - assert state == snapshot(name=f"{entity_entry.entity_id}-state") + assert state == snapshot( + name=f"{entity_entry.entity_id}-state", matcher=sort_event_types + ) async def force_uptime_value( @@ -179,3 +210,23 @@ async def force_uptime_value( """Force time to a specific point.""" await hass.config.async_set_time_zone("UTC") freezer.move_to("2025-05-26 16:04:00+00:00") + + +@contextmanager +def patch_platforms(platforms: list[Platform]): + """Only allow given platforms to be loaded.""" + with ( + patch( + "homeassistant.components.shelly.PLATFORMS", + list(set(PLATFORMS) & set(platforms)), + ), + patch( + "homeassistant.components.shelly.BLOCK_SLEEPING_PLATFORMS", + list(set(BLOCK_SLEEPING_PLATFORMS) & set(platforms)), + ), + patch( + "homeassistant.components.shelly.RPC_SLEEPING_PLATFORMS", + list(set(RPC_SLEEPING_PLATFORMS) & set(platforms)), + ), + ): + yield diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 47ff723bddc..7402d835ad1 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -324,6 +324,7 @@ MOCK_BLU_TRV_REMOTE_STATUS = { "rssi": -60, "battery": 100, "errors": [], + "fw_ver": "v1.2.10", }, "blutrv:201": { "id": 0, @@ -761,3 +762,16 @@ def mock_setup() -> Generator[AsyncMock]: "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup: yield mock_setup + + +@pytest.fixture +def disable_async_remove_shelly_rpc_entities() -> Generator[None]: + """Patch out async_remove_shelly_rpc_entities. + + This is used by virtual components tests that should not create entities, + without it async_remove_shelly_rpc_entities will clean up the entities. + """ + with patch( + "homeassistant.components.shelly.utils.async_remove_shelly_rpc_entities" + ): + yield diff --git a/tests/components/shelly/fixtures/st1820_gen3.json b/tests/components/shelly/fixtures/st1820_gen3.json new file mode 100644 index 00000000000..b2795161dc3 --- /dev/null +++ b/tests/components/shelly/fixtures/st1820_gen3.json @@ -0,0 +1,321 @@ +{ + "config": { + "ble": { + "enable": true, + "rpc": { + "enable": true + } + }, + "boolean:200": { + "access": "crw", + "default_value": false, + "id": 200, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Disabled", "Enabled"], + "view": "toggle" + } + }, + "name": "Anti-freeze", + "owner": "service:0", + "persisted": false, + "role": "anti_freeze" + }, + "boolean:201": { + "access": "crw", + "default_value": false, + "id": 201, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Disabled", "Enabled"], + "view": "toggle" + } + }, + "name": "Child lock", + "owner": "service:0", + "persisted": false, + "role": "child_lock" + }, + "boolean:202": { + "access": "crw", + "default_value": false, + "id": 202, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Disabled", "Enable"], + "view": "toggle" + } + }, + "name": "Enable thermostat", + "owner": "service:0", + "persisted": false, + "role": "enable" + }, + "bthome": {}, + "cloud": { + "enable": false, + "server": "wss://repo.shelly.cloud:6022/jrpc" + }, + "mqtt": { + "client_id": "st1820-aabbccddeeff", + "enable": false, + "enable_control": true, + "enable_rpc": true, + "rpc_ntf": true, + "server": "mqtt.test.server", + "ssl_ca": null, + "status_ntf": false, + "topic_prefix": "st1820-aabbccddeeff", + "use_client_cert": false, + "user": null + }, + "number:200": { + "access": "cr", + "default_value": 0, + "id": 200, + "max": 100, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "unit": "%", + "view": "label" + } + }, + "min": 0, + "name": "Current humidity", + "owner": "service:0", + "persisted": false, + "role": "current_humidity" + }, + "number:201": { + "access": "cr", + "default_value": 25, + "id": 201, + "max": 35, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "unit": "°C", + "view": "label" + } + }, + "min": 15, + "name": "Current temperature", + "owner": "service:0", + "persisted": false, + "role": "current_temperature" + }, + "number:202": { + "access": "crw", + "default_value": 25, + "id": 202, + "max": 35, + "meta": { + "cloud": ["log"], + "ui": { + "step": 0.5, + "unit": "°C", + "view": "slider" + } + }, + "min": 15, + "name": "Target temperature", + "owner": "service:0", + "persisted": false, + "role": "target_temperature" + }, + "service:0": { + "humidity_offset": 0, + "id": 0, + "power_down_memory": true, + "temp_anti_freeze": 5, + "temp_hysteresis": 1, + "temp_offset": 0, + "temp_range": [15, 35] + }, + "sys": { + "cfg_rev": 34, + "debug": { + "file_level": null, + "level": 2, + "mqtt": { + "enable": false + }, + "udp": { + "addr": null + }, + "websocket": { + "enable": false + } + }, + "device": { + "discoverable": true, + "eco_mode": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "mac": "AABBCCDDEEFF", + "name": "Test Name" + }, + "location": { + "lat": 32.1033, + "lon": 34.8879, + "tz": "Asia/Jerusalem" + }, + "rpc_udp": { + "dst_addr": null, + "listen_port": null + }, + "sntp": { + "server": "sntp.test.server" + }, + "ui_data": {} + }, + "wifi": { + "ap": { + "enable": true, + "is_open": true, + "range_extender": { + "enable": false + }, + "ssid": "ST1820-AABBCCDDEEFF" + }, + "roam": { + "interval": 60, + "rssi_thr": -80 + }, + "sta": { + "enable": true, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "is_open": false, + "nameserver": null, + "netmask": null, + "ssid": "Wifi-Network-Name" + }, + "sta1": { + "enable": false, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "is_open": true, + "nameserver": null, + "netmask": null, + "ssid": null + } + }, + "ws": { + "enable": false, + "server": null, + "ssl_ca": "ca.pem" + } + }, + "shelly": { + "app": "XT1", + "auth_domain": null, + "auth_en": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "gen": 3, + "id": "st1820-aabbccddeeff", + "jti": "00023E000007", + "jwt": { + "aud": "XT1", + "f": 1, + "iat": 1733130983, + "jti": "00023E000007", + "n": "Starlight Thermostat ST1820", + "p": "ST1820", + "url": "https://www.linkedgo-e.com/Floor_Heating_Thermostat.html", + "v": 1, + "xt1": { + "svc0": { + "type": "linkedgo-st1820-floor-thermostat" + } + } + }, + "mac": "AABBCCDDEEFF", + "model": "S3XT-0S", + "name": "Test Name", + "slot": 0, + "svc0": { + "build_id": "20241206-114057/aafe9c3", + "type": "linkedgo-st1820-floor-thermostat", + "ver": "0.5.2-st1820-prod0" + }, + "ver": "1.4.99-xt-prod1" + }, + "status": { + "ble": {}, + "boolean:200": { + "value": false + }, + "boolean:201": { + "value": true + }, + "boolean:202": { + "value": true + }, + "bthome": {}, + "cloud": { + "connected": false + }, + "mqtt": { + "connected": false + }, + "number:200": { + "value": 59 + }, + "number:201": { + "value": 25.5 + }, + "number:202": { + "value": 27 + }, + "service:0": { + "etag": "c592bdbf305187d367eb573b3576b074", + "state": "running", + "stats": { + "mem": 800, + "mem_peak": 935 + } + }, + "sys": { + "available_updates": { + "stable": { + "svc0": { + "ver": "0.7.0" + }, + "version": "1.5.1" + } + }, + "btrelay_rev": 0, + "cfg_rev": 34, + "fs_free": 585728, + "fs_size": 1048576, + "kvs_rev": 0, + "last_sync_ts": 1759408493, + "mac": "AABBCCDDEEFF", + "ram_free": 47748, + "ram_min_free": 34092, + "ram_size": 252564, + "reset_reason": 1, + "restart_required": false, + "schedule_rev": 0, + "time": "15:36", + "unixtime": 1759408575, + "uptime": 18088, + "webhook_rev": 1 + }, + "wifi": { + "rssi": -41, + "ssid": "Wifi-Network-Name", + "sta_ip": "192.168.2.24", + "status": "got ip" + }, + "ws": { + "connected": false + } + } +} diff --git a/tests/components/shelly/fixtures/st802_gen3.json b/tests/components/shelly/fixtures/st802_gen3.json new file mode 100644 index 00000000000..afc9f095004 --- /dev/null +++ b/tests/components/shelly/fixtures/st802_gen3.json @@ -0,0 +1,373 @@ +{ + "config": { + "ble": { + "enable": true, + "rpc": { + "enable": true + } + }, + "boolean:200": { + "access": "crw", + "default_value": false, + "id": 200, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Off", "On"], + "view": "toggle" + } + }, + "name": "Anti-Freeze", + "owner": "service:0", + "persisted": false, + "role": "anti_freeze" + }, + "boolean:201": { + "access": "crw", + "default_value": false, + "id": 201, + "meta": { + "cloud": ["log"], + "ui": { + "titles": ["Disabled", "Enabled"], + "view": "toggle" + } + }, + "name": "Enable thermostat", + "owner": "service:0", + "persisted": false, + "role": "enable" + }, + "bthome": {}, + "cloud": { + "enable": false, + "server": "wss://repo.shelly.cloud:6022/jrpc" + }, + "enum:200": { + "access": "crw", + "default_value": "auto", + "id": 200, + "meta": { + "cloud": ["log"], + "ui": { + "titles": { + "auto": "Auto", + "high": "High", + "low": "Low", + "medium": "Medium", + "strong": "Strong", + "whisper": "Whisper" + }, + "view": "select" + } + }, + "name": "Fan speed", + "options": ["auto", "low", "medium", "high"], + "owner": "service:0", + "persisted": false, + "role": "fan_speed" + }, + "enum:201": { + "access": "crw", + "default_value": "cool", + "id": 201, + "meta": { + "ui": { + "titles": { + "boost": "Boost", + "cool": "Cool", + "dry": "Dry", + "floor_heating": "Floor heating", + "heat": "Heat", + "ventilation": "Ventilation" + }, + "view": "select" + } + }, + "name": "Working mode", + "options": ["cool", "dry", "heat", "ventilation"], + "owner": "service:0", + "persisted": false, + "role": "working_mode" + }, + "mqtt": { + "client_id": "st-802-aabbccddeeff", + "enable": false, + "enable_control": true, + "enable_rpc": true, + "rpc_ntf": true, + "server": "mqtt.test.server", + "ssl_ca": null, + "status_ntf": false, + "topic_prefix": "st-802-aabbccddeeff", + "use_client_cert": false, + "user": null + }, + "number:200": { + "access": "crw", + "default_value": 0, + "id": 200, + "max": 100, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "unit": "%", + "view": "label" + } + }, + "min": 0, + "name": "Current humidity", + "owner": "service:0", + "persisted": false, + "role": "current_humidity" + }, + "number:201": { + "access": "crw", + "default_value": 20, + "id": 201, + "max": 35, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "step": 0.5, + "unit": "°C", + "view": "label" + } + }, + "min": 5, + "name": "Current temperature", + "owner": "service:0", + "persisted": false, + "role": "current_temperature" + }, + "number:202": { + "access": "crw", + "default_value": 45, + "id": 202, + "max": 75, + "meta": { + "cloud": ["log"], + "ui": { + "unit": "%", + "view": "slider" + } + }, + "min": 40, + "name": "Target humidity", + "owner": "service:0", + "persisted": false, + "role": "target_humidity" + }, + "number:203": { + "access": "crw", + "default_value": 20, + "id": 203, + "max": 35, + "meta": { + "cloud": ["log"], + "ui": { + "step": 0.5, + "unit": "°C", + "view": "slider" + } + }, + "min": 5, + "name": "Target temperature", + "owner": "service:0", + "persisted": false, + "role": "target_temperature" + }, + "service:0": { + "id": 0, + "temp_unit": "C", + "thermostat_mode": "auto" + }, + "sys": { + "cfg_rev": 70, + "debug": { + "file_level": null, + "level": 2, + "mqtt": { + "enable": false + }, + "udp": { + "addr": null + }, + "websocket": { + "enable": false + } + }, + "device": { + "discoverable": true, + "eco_mode": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "mac": "AABBCCDDEEFF", + "name": "Test Name" + }, + "location": { + "lat": 32.1033, + "lon": 34.8879, + "tz": "Asia/Jerusalem" + }, + "rpc_udp": { + "dst_addr": null, + "listen_port": null + }, + "sntp": { + "server": "sntp.test.server" + }, + "ui_data": {} + }, + "wifi": { + "ap": { + "enable": true, + "is_open": true, + "range_extender": { + "enable": false + }, + "ssid": "ST-802-AABBCCDDEEFF" + }, + "roam": { + "interval": 60, + "rssi_thr": -80 + }, + "sta": { + "enable": true, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "is_open": false, + "nameserver": null, + "netmask": null, + "ssid": "Wifi-Network-Name" + }, + "sta1": { + "enable": false, + "gw": null, + "ip": null, + "ipv4mode": "dhcp", + "is_open": true, + "nameserver": null, + "netmask": null, + "ssid": null + } + }, + "ws": { + "enable": false, + "server": null, + "ssl_ca": "ca.pem" + } + }, + "shelly": { + "app": "XT1", + "auth_domain": null, + "auth_en": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "gen": 3, + "id": "st-802-aabbccddeeff", + "jti": "00023E000006", + "jwt": { + "aud": "XT1", + "f": 1, + "iat": 1732626411, + "jti": "00023E000006", + "n": "Youth Smart Thermostat ST802", + "p": "ST-802", + "url": "https://www.linkedgo-e.com/", + "v": 1, + "xt1": { + "svc0": { + "type": "linkedgo-st-802-hvac" + } + } + }, + "mac": "AABBCCDDEEFF", + "model": "S3XT-0S", + "name": "Test Name", + "slot": 0, + "svc0": { + "build_id": "20241126-134710/490e7db", + "type": "linkedgo-st-802-hvac", + "ver": "0.5.2-st802-prod0" + }, + "ver": "1.4.99-xt-prod1" + }, + "status": { + "ble": {}, + "boolean:200": { + "value": false + }, + "boolean:201": { + "value": true + }, + "bthome": {}, + "cloud": { + "connected": false + }, + "enum:200": { + "value": "auto" + }, + "enum:201": { + "value": "heat" + }, + "mqtt": { + "connected": false + }, + "number:200": { + "value": 58 + }, + "number:201": { + "value": 25.1 + }, + "number:202": { + "value": 60 + }, + "number:203": { + "value": 20.5 + }, + "service:0": { + "etag": "49da4f1517e4f8a548cb3b1491d14597", + "state": "running", + "stats": { + "mem": 655, + "mem_peak": 786 + } + }, + "sys": { + "available_updates": { + "stable": { + "svc0": { + "ver": "0.7.0" + }, + "version": "1.5.1" + } + }, + "btrelay_rev": 0, + "cfg_rev": 70, + "fs_free": 589824, + "fs_size": 1048576, + "kvs_rev": 0, + "last_sync_ts": 1759408492, + "mac": "AABBCCDDEEFF", + "ram_free": 46756, + "ram_min_free": 24900, + "ram_size": 252388, + "reset_reason": 1, + "restart_required": false, + "schedule_rev": 0, + "time": "15:51", + "unixtime": 1759409476, + "uptime": 18989, + "webhook_rev": 1 + }, + "wifi": { + "rssi": -46, + "ssid": "Wifi-Network-Name", + "sta_ip": "192.168.2.24", + "status": "got ip" + }, + "ws": { + "connected": false + } + } +} diff --git a/tests/components/shelly/fixtures/wall_display_xl.json b/tests/components/shelly/fixtures/wall_display_xl.json new file mode 100644 index 00000000000..6b611220258 --- /dev/null +++ b/tests/components/shelly/fixtures/wall_display_xl.json @@ -0,0 +1,307 @@ +{ + "config": { + "ble": { + "enable": false, + "keep_running": true, + "rpc": { + "enable": true + }, + "observer": { + "enable": false + } + }, + "wifi": { + "sta": { + "enable": true, + "ssid": "Wifi-Network-Name", + "roam_interval": 900, + "is_open": false, + "ipv4mode": "dhcp", + "ip": "192.168.2.81", + "netmask": "255.255.255.0", + "gw": "192.168.2.1", + "nameserver": "192.168.2.1" + } + }, + "switch:0": { + "in_mode": "detached", + "id": 0, + "auto_off": false, + "auto_on_delay": 0, + "initial_state": "off", + "name": null + }, + "input:0": { + "type": "button", + "id": 0, + "invert": false, + "factory_reset": true, + "name": null + }, + "input:1": { + "id": 1, + "type": "switch", + "invert": false, + "factory_reset": true, + "name": null + }, + "input:2": { + "id": 2, + "type": "switch", + "invert": false, + "factory_reset": true, + "name": null + }, + "input:3": { + "id": 3, + "type": "button", + "invert": false, + "factory_reset": true, + "name": null + }, + "input:4": { + "id": 4, + "type": "button", + "invert": false, + "factory_reset": true, + "name": null + }, + "temperature:0": { + "id": 0, + "report_thr_C": 1, + "offset_C": 0, + "name": null + }, + "humidity:0": { + "id": 0, + "report_thr": 1, + "offset": 0, + "name": null + }, + "illuminance:0": { + "id": 0, + "bright_thr": 200, + "dark_thr": 30, + "name": null + }, + "ui": { + "lock_type": "none", + "disable_gestures_when_locked": false, + "use_F": false, + "screen_saver": { + "enable": false, + "timeout": 20, + "priority_element": "CLOCK" + }, + "screen_off_when_idle": false, + "brightness": { + "auto": true, + "level": 70, + "auto_off": { + "enable": false, + "by_lux": false + } + }, + "relay_state_overlay": { + "enable": true, + "always_visible": false + } + }, + "sys": { + "cfg_rev": 50, + "device": { + "fw_id": "20250923-131544/2.4.4-5c68f1d6", + "mac": "AABBCCDDEEFF", + "discoverable": false, + "name": null + }, + "location": { + "tz": "Europe/Brussels", + "lat": 99.8888, + "lon": 22.3333 + }, + "sntp": { + "server": "time.google.com" + }, + "debug": { + "websocket": { + "enable": false + }, + "mqtt": { + "enable": false + }, + "logs": { + "Generic": true, + "Bluetooth": true, + "Cloud": true, + "Interface": true, + "Media": true, + "MQTT": true, + "Network": true, + "RPC": true, + "Thermostat": true, + "Screen": true, + "ShellySmartControl": true, + "Webhooks": true, + "WebSocket": true + } + }, + "media_player_enabled": true + }, + "cloud": { + "server": "shelly-105-eu.shelly.cloud:6022/jrpc", + "enable": true + }, + "mqtt": { + "enable": false, + "client_id": "ShellyWallDisplay-AABBCCDDEEFF", + "topic_prefix": "ShellyWallDisplay-AABBCCDDEEFF" + }, + "ws": { + "enable": false, + "ssl_ca": "ca.pem" + }, + "media": { + "rev": 0 + } + }, + "shelly": { + "id": "ShellyWallDisplay-AABBCCDDEEFF", + "mac": "AABBCCDDEEFF", + "model": "SAWD-3A1XE10EU2", + "gen": 2, + "fw_id": "20250923-131544/2.4.4-5c68f1d6", + "ver": "2.4.4", + "app": "WallDisplayV2", + "auth_en": false, + "uptime": 930619, + "app_uptime": 61029, + "ram_size": 268435456, + "ram_free": 50023040, + "fs_size": 24480665600, + "fs_free": 24071430144, + "discoverable": false, + "cfg_rev": 50, + "schedule_rev": 0, + "webhook_rev": 22, + "platform": "vBlake.a21b392", + "serial": "ABCDFE5674", + "batch_id": "3d35b", + "batch_date": 250715, + "available_updates": {}, + "restart_required": false, + "unixtime": 1759216204, + "relay_in_thermostat": false, + "sensor_in_thermostat": false, + "awaiting_auth_code": false, + "ch": ["switch:0"] + }, + "status": { + "ble": {}, + "cloud": { + "connected": true + }, + "mqtt": { + "connected": false + }, + "temperature:0": { + "id": 0, + "tC": -275.1499938964844, + "tF": -463.2, + "errors": ["Sensor driver missing from firmware"] + }, + "humidity:0": { + "id": 0, + "rh": -2, + "errors": ["Sensor driver missing from firmware"] + }, + "illuminance:0": { + "id": 0, + "lux": 120, + "illumination": "twilight" + }, + "switch:0": { + "id": 0, + "output": true, + "source": "RPC Set" + }, + "input:0": { + "id": 0, + "state": false + }, + "input:1": { + "id": 1 + }, + "input:2": { + "id": 2, + "state": true + }, + "input:3": { + "id": 3 + }, + "input:4": { + "id": 4 + }, + "sys": { + "id": "ShellyWallDisplay-AABBCCDDEEFF", + "mac": "AABBCCDDEEFF", + "model": "SAWD-3A1XE10EU2", + "gen": 2, + "fw_id": "20250923-131544/2.4.4-5c68f1d6", + "ver": "2.4.4", + "app": "WallDisplayV2", + "auth_en": false, + "uptime": 930619, + "app_uptime": 61029, + "ram_size": 268435456, + "ram_free": 50023040, + "fs_size": 24480665600, + "fs_free": 24071430144, + "discoverable": false, + "cfg_rev": 50, + "schedule_rev": 0, + "webhook_rev": 22, + "platform": "vBlake.a21b392", + "serial": "SAWD9570149AV", + "batch_id": "3d35b", + "batch_date": 250715, + "available_updates": {}, + "restart_required": false, + "unixtime": 1759216205, + "relay_in_thermostat": false, + "sensor_in_thermostat": false, + "awaiting_auth_code": false, + "ch": ["switch:0"] + }, + "wifi": { + "sta_ip": "192.168.2.81", + "status": "got ip", + "mac": "00:A9:0B:70:14:9A", + "ssid": "Wifi-Network-Name", + "rssi": -48, + "netmask": "255.255.255.0", + "gw": "192.168.2.1", + "nameserver": "192.168.2.1" + }, + "media": { + "playback": { + "enable": false, + "buffering": false, + "volume": 7 + }, + "total_size": 3885854, + "total_size_h": "3.706 MB", + "item_counts": { + "audio": 0, + "photo": 0, + "video": 0 + } + }, + "devicepower:0": { + "id": 0, + "external": { + "present": true + } + } + } +} diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr index 201f20c3de9..5388dcfedc6 100644 --- a/tests/components/shelly/snapshots/test_binary_sensor.ambr +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -48,6 +48,55 @@ 'state': 'off', }) # --- +# name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_cable_unplugged-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.test_name_kitchen_cable_unplugged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen cable unplugged', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-flood:0-flood_cable_unplugged', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_cable_unplugged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Kitchen cable unplugged', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_kitchen_cable_unplugged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_rpc_flood_entities[binary_sensor.test_name_kitchen_flood-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr index 09c2c5f3d8d..af19860f546 100644 --- a/tests/components/shelly/snapshots/test_button.ambr +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'calibrate', - 'unique_id': 'f8:44:77:25:f0:dd_calibrate', + 'unique_id': 'F8447725F0DD-blutrv:200-calibrate', 'unit_of_measurement': None, }) # --- @@ -78,7 +78,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC_reboot', + 'unique_id': '123456789ABC-reboot', 'unit_of_measurement': None, }) # --- @@ -96,3 +96,99 @@ 'state': 'unknown', }) # --- +# name: test_rpc_device_virtual_button[button.test_name_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_name_button', + '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': 'Button', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-button:200', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_device_virtual_button[button.test_name_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Button', + }), + 'context': , + 'entity_id': 'button.test_name_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_wall_display_virtual_button[button.test_name_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_name_button', + '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': 'Button', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-button:200', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_virtual_button[button.test_name_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Button', + }), + 'context': , + 'entity_id': 'button.test_name_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_climate.ambr b/tests/components/shelly/snapshots/test_climate.ambr index 35746dd5c08..ebc03966a0b 100644 --- a/tests/components/shelly/snapshots/test_climate.ambr +++ b/tests/components/shelly/snapshots/test_climate.ambr @@ -210,6 +210,182 @@ 'state': 'heat', }) # --- +# name: test_rpc_linkedgo_st1820_thermostat[climate.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 15, + 'preset_modes': list([ + 'none', + 'frost_protection', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name', + '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': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '123456789ABC-number:202-linkedgo_thermostat_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_linkedgo_st1820_thermostat[climate.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 59, + 'current_temperature': 25.5, + 'friendly_name': 'Test name', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 15, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'frost_protection', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 27, + }), + 'context': , + 'entity_id': 'climate.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_rpc_linkedgo_st802_thermostat[climate.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_humidity': 75, + 'max_temp': 35, + 'min_humidity': 40, + 'min_temp': 5, + 'preset_modes': list([ + 'none', + 'frost_protection', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name', + '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': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '123456789ABC-number:203-linkedgo_thermostat_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_linkedgo_st802_thermostat[climate.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 58, + 'current_temperature': 25.1, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Test name', + 'humidity': 60, + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_humidity': 75, + 'max_temp': 35, + 'min_humidity': 40, + 'min_temp': 5, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'frost_protection', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 20.5, + }), + 'context': , + 'entity_id': 'climate.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_wall_display_thermostat_mode[climate.test_name-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 9dcda321057..06b9acedf03 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -422,7 +422,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC_reboot', + 'unique_id': '123456789ABC-reboot', 'unit_of_measurement': None, }) # --- @@ -546,65 +546,6 @@ 'state': '0.0', }) # --- -# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-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.test_name_energy', - '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': 'Energy', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456789ABC-cover:0-energy', - 'unit_of_measurement': , - }) -# --- -# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Test name Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_name_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_shelly_2pm_gen3_cover[sensor.test_name_frequency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -826,6 +767,65 @@ 'state': '36.4', }) # --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-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.test_name_energy', + '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': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1672,7 +1672,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC_reboot', + 'unique_id': '123456789ABC-reboot', 'unit_of_measurement': None, }) # --- @@ -1743,6 +1743,65 @@ 'state': '-52', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy_consumed-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.test_name_switch_0_energy_consumed', + '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': 'Energy consumed', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-consumed_energy_switch', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 0 Energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1799,65 +1858,6 @@ 'state': '0.0', }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-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.test_name_switch_0_energy', - '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': 'Energy', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456789ABC-switch:0-energy', - 'unit_of_measurement': , - }) -# --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Test name Switch 0 Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_name_switch_0_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_frequency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1970,7 +1970,7 @@ 'state': '0.0', }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_returned_energy-entry] +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy_returned-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1985,7 +1985,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_switch_0_returned_energy', + 'entity_id': 'sensor.test_name_switch_0_energy_returned', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2003,7 +2003,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Returned energy', + 'original_name': 'Energy returned', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2013,16 +2013,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_returned_energy-state] +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy_returned-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Switch 0 Returned energy', + 'friendly_name': 'Test name Switch 0 Energy returned', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_switch_0_returned_energy', + 'entity_id': 'sensor.test_name_switch_0_energy_returned', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2085,6 +2085,65 @@ 'state': '40.6', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-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.test_name_switch_0_energy', + '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': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 0 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2141,6 +2200,65 @@ 'state': '216.2', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy_consumed-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.test_name_switch_1_energy_consumed', + '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': 'Energy consumed', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-consumed_energy_switch', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 1 Energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2197,65 +2315,6 @@ 'state': '0.0', }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-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.test_name_switch_1_energy', - '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': 'Energy', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456789ABC-switch:1-energy', - 'unit_of_measurement': , - }) -# --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Test name Switch 1 Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_name_switch_1_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_frequency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2368,7 +2427,7 @@ 'state': '0.0', }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_returned_energy-entry] +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy_returned-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2383,7 +2442,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_switch_1_returned_energy', + 'entity_id': 'sensor.test_name_switch_1_energy_returned', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2401,7 +2460,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Returned energy', + 'original_name': 'Energy returned', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2411,16 +2470,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_returned_energy-state] +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy_returned-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Switch 1 Returned energy', + 'friendly_name': 'Test name Switch 1 Energy returned', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_switch_1_returned_energy', + 'entity_id': 'sensor.test_name_switch_1_energy_returned', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2483,6 +2542,65 @@ 'state': '40.6', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-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.test_name_switch_1_energy', + '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': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 1 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2935,7 +3053,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC_reboot', + 'unique_id': '123456789ABC-reboot', 'unit_of_measurement': None, }) # --- @@ -3229,7 +3347,7 @@ 'state': '0.99', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_energy-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_a_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3244,7 +3362,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_a_total_active_energy', + 'entity_id': 'sensor.test_name_phase_a_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3262,7 +3380,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active energy', + 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3272,23 +3390,23 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_energy-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_a_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Phase A Total active energy', + 'friendly_name': 'Test name Phase A Energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_a_total_active_energy', + 'entity_id': 'sensor.test_name_phase_a_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '3105.57642', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_returned_energy-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_a_energy_returned-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3303,7 +3421,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_a_total_active_returned_energy', + 'entity_id': 'sensor.test_name_phase_a_energy_returned', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3321,7 +3439,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active returned energy', + 'original_name': 'Energy returned', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3331,16 +3449,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_returned_energy-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_a_energy_returned-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Phase A Total active returned energy', + 'friendly_name': 'Test name Phase A Energy returned', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_a_total_active_returned_energy', + 'entity_id': 'sensor.test_name_phase_a_energy_returned', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3679,7 +3797,7 @@ 'state': '0.36', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_energy-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_b_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3694,7 +3812,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_b_total_active_energy', + 'entity_id': 'sensor.test_name_phase_b_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3712,7 +3830,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active energy', + 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3722,23 +3840,23 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_energy-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_b_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Phase B Total active energy', + 'friendly_name': 'Test name Phase B Energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_b_total_active_energy', + 'entity_id': 'sensor.test_name_phase_b_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '195.76572', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_returned_energy-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_b_energy_returned-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3753,7 +3871,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_b_total_active_returned_energy', + 'entity_id': 'sensor.test_name_phase_b_energy_returned', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3771,7 +3889,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active returned energy', + 'original_name': 'Energy returned', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3781,16 +3899,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_returned_energy-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_b_energy_returned-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Phase B Total active returned energy', + 'friendly_name': 'Test name Phase B Energy returned', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_b_total_active_returned_energy', + 'entity_id': 'sensor.test_name_phase_b_energy_returned', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4129,7 +4247,7 @@ 'state': '0.72', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_energy-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_c_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4144,7 +4262,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_c_total_active_energy', + 'entity_id': 'sensor.test_name_phase_c_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4162,7 +4280,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active energy', + 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4172,23 +4290,23 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_energy-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_c_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Phase C Total active energy', + 'friendly_name': 'Test name Phase C Energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_c_total_active_energy', + 'entity_id': 'sensor.test_name_phase_c_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2114.07205', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_returned_energy-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_c_energy_returned-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4203,7 +4321,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_c_total_active_returned_energy', + 'entity_id': 'sensor.test_name_phase_c_energy_returned', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4221,7 +4339,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active returned energy', + 'original_name': 'Energy returned', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4231,16 +4349,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_returned_energy-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_c_energy_returned-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Phase C Total active returned energy', + 'friendly_name': 'Test name Phase C Energy returned', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_c_total_active_returned_energy', + 'entity_id': 'sensor.test_name_phase_c_energy_returned', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4468,7 +4586,7 @@ 'state': '46.3', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_active_energy-entry] +# name: test_shelly_pro_3em[sensor.test_name_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4483,7 +4601,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_total_active_energy', + 'entity_id': 'sensor.test_name_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4501,7 +4619,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active energy', + 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4511,16 +4629,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_active_energy-state] +# name: test_shelly_pro_3em[sensor.test_name_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Total active energy', + 'friendly_name': 'Test name Energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_total_active_energy', + 'entity_id': 'sensor.test_name_energy', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4583,7 +4701,7 @@ 'state': '2413.825', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_active_returned_energy-entry] +# name: test_shelly_pro_3em[sensor.test_name_energy_returned-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4598,7 +4716,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_total_active_returned_energy', + 'entity_id': 'sensor.test_name_energy_returned', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4616,7 +4734,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active returned energy', + 'original_name': 'Energy returned', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4626,16 +4744,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_active_returned_energy-state] +# name: test_shelly_pro_3em[sensor.test_name_energy_returned-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name Total active returned energy', + 'friendly_name': 'Test name Energy returned', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_total_active_returned_energy', + 'entity_id': 'sensor.test_name_energy_returned', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4925,3 +5043,922 @@ 'state': 'off', }) # --- +# name: test_wall_display_xl[binary_sensor.test_name_cloud-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.test_name_cloud', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cloud', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cloud-cloud', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[binary_sensor.test_name_cloud-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test name Cloud', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_wall_display_xl[binary_sensor.test_name_external_power-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.test_name_external_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-devicepower:0-external_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[binary_sensor.test_name_external_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name External power', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_external_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_wall_display_xl[binary_sensor.test_name_input_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_name_input_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 2', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:2-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[binary_sensor.test_name_input_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 2', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_wall_display_xl[binary_sensor.test_name_restart_required-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.test_name_restart_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart required', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[binary_sensor.test_name_restart_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Restart required', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_wall_display_xl[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_wall_display_xl[event.test_name_input_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_name_input_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'input', + 'unique_id': '123456789ABC-input:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[event.test_name_input_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'btn_down', + 'btn_up', + 'double_push', + 'long_push', + 'single_push', + 'triple_push', + ]), + 'friendly_name': 'Test name Input 0', + }), + 'context': , + 'entity_id': 'event.test_name_input_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_wall_display_xl[event.test_name_input_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_name_input_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 3', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'input', + 'unique_id': '123456789ABC-input:3', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[event.test_name_input_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'btn_down', + 'btn_up', + 'double_push', + 'long_push', + 'single_push', + 'triple_push', + ]), + 'friendly_name': 'Test name Input 3', + }), + 'context': , + 'entity_id': 'event.test_name_input_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_wall_display_xl[event.test_name_input_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_name_input_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 4', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'input', + 'unique_id': '123456789ABC-input:4', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[event.test_name_input_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'btn_down', + 'btn_up', + 'double_push', + 'long_push', + 'single_push', + 'triple_push', + ]), + 'friendly_name': 'Test name Input 4', + }), + 'context': , + 'entity_id': 'event.test_name_input_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_humidity-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.test_name_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-humidity:0-humidity_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test name Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_name_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_illuminance-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.test_name_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-illuminance:0-illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test name Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_name_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_illuminance_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'dark', + 'twilight', + 'bright', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_illuminance_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance level', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance_level', + 'unique_id': '123456789ABC-illuminance:0-illuminance_illumination', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[sensor.test_name_illuminance_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test name Illuminance level', + 'options': list([ + 'dark', + 'twilight', + 'bright', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_name_illuminance_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'twilight', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_rssi-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': , + 'entity_id': 'sensor.test_name_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Test name RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_name_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-48', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_temperature-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.test_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-temperature:0-temperature_0', + 'unit_of_measurement': , + }) +# --- +# name: test_wall_display_xl[sensor.test_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-275.149993896484', + }) +# --- +# name: test_wall_display_xl[sensor.test_name_uptime-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.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-15T21:33:41+00:00', + }) +# --- +# name: test_wall_display_xl[switch.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_name', + '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': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[switch.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name', + }), + 'context': , + 'entity_id': 'switch.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_wall_display_xl[update.test_name_beta_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_name_beta_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Beta firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate_beta', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[update.test_name_beta_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Beta firmware', + 'in_progress': False, + 'installed_version': '2.4.4', + 'latest_version': '2.4.4', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_beta_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_wall_display_xl[update.test_name_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_xl[update.test_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Firmware', + 'in_progress': False, + 'installed_version': '2.4.4', + 'latest_version': '2.4.4', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr index 4b12dddae62..2f09492351e 100644 --- a/tests/components/shelly/snapshots/test_sensor.ambr +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -157,6 +157,306 @@ 'state': '0', }) # --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_charger_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charger_charging', + 'charger_end', + 'charger_fault', + 'charger_free', + 'charger_free_fault', + 'charger_insert', + 'charger_pause', + 'charger_wait', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_charger_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger state', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_state', + 'unique_id': '123456789ABC-number:200-number_work_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_charger_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test name Charger state', + 'options': list([ + 'charger_charging', + 'charger_end', + 'charger_fault', + 'charger_free', + 'charger_free_fault', + 'charger_insert', + 'charger_pause', + 'charger_wait', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_name_charger_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charger_charging', + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_duration-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': None, + 'entity_id': 'sensor.test_name_session_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session duration', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-number:202-number_time_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test name Session duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_session_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_energy-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.test_name_session_energy', + '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': 'Session energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-number:201-number_energy_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Session energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_session_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy_consumed-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.test_name_test_switch_0_energy_consumed', + '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': 'test switch_0 energy consumed', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-consumed_energy_switch', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name test switch_0 energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_test_switch_0_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1135.80246', + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy_returned-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.test_name_test_switch_0_energy_returned', + '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': 'test switch_0 energy returned', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy_returned-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name test switch_0 energy returned', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_test_switch_0_energy_returned', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98.76543', + }) +# --- # name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -216,62 +516,3 @@ 'state': '1234.56789', }) # --- -# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_returned_energy-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.test_name_test_switch_0_returned_energy', - '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': 'test switch_0 returned energy', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456789ABC-switch:0-ret_energy', - 'unit_of_measurement': , - }) -# --- -# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_returned_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Test name test switch_0 returned energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_name_test_switch_0_returned_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '98.76543', - }) -# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index f67e0bbb564..090a0b47c3c 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -10,7 +10,13 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -19,8 +25,10 @@ from . import ( init_integration, mock_rest_update, mutate_rpc_device_status, + patch_platforms, register_device, register_entity, + register_sub_device, ) from tests.common import mock_restore_cache @@ -29,6 +37,13 @@ RELAY_BLOCK_ID = 0 SENSOR_BLOCK_ID = 3 +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.BINARY_SENSOR]): + yield + + async def test_block_binary_sensor( hass: HomeAssistant, mock_block_device: Mock, @@ -53,26 +68,24 @@ async def test_block_binary_sensor( assert entry.unique_id == "123456789ABC-relay_0-overpower" -async def test_block_binary_sensor_extra_state_attr( +async def test_block_binary_gas_sensor_creation( hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, entity_registry: EntityRegistry, ) -> None: - """Test block binary sensor extra state attributes.""" + """Test block binary gas sensor creation.""" entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_gas" await init_integration(hass, 1) assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert state.attributes.get("detected") == "mild" monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "gas", "none") mock_block_device.mock_update() assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF - assert state.attributes.get("detected") == "none" assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-gas" @@ -436,6 +449,7 @@ async def test_rpc_device_virtual_binary_sensor( assert state.state == STATE_OFF +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -477,8 +491,10 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( ) -> None: """Check whether the virtual binary sensor will be removed if it has been removed from the device configuration.""" config_entry = await init_integration(hass, 3, skip_setup=True) + + # create orphaned entity on main device device_entry = register_device(device_registry, config_entry) - entity_id = register_entity( + entity_id1 = register_entity( hass, BINARY_SENSOR_DOMAIN, "test_name_boolean_200", @@ -487,10 +503,29 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( device_id=device_entry.id, ) + # create orphaned entity on sub device + sub_device_entry = register_sub_device( + device_registry, + config_entry, + "boolean:201-boolean", + ) + entity_id2 = register_entity( + hass, + BINARY_SENSOR_DOMAIN, + "boolean_201", + "boolean:201-boolean", + config_entry, + device_id=sub_device_entry.id, + ) + + assert entity_registry.async_get(entity_id1) is not None + assert entity_registry.async_get(entity_id2) is not None + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert entity_registry.async_get(entity_id) is None + assert entity_registry.async_get(entity_id1) is None + assert entity_registry.async_get(entity_id2) is None async def test_blu_trv_binary_sensor_entity( @@ -521,7 +556,7 @@ async def test_rpc_flood_entities( """Test RPC flood sensor entities.""" await init_integration(hass, 4) - for entity in ("flood", "mute"): + for entity in ("flood", "mute", "cable_unplugged"): entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_kitchen_{entity}" state = hass.states.get(entity_id) @@ -529,3 +564,109 @@ async def test_rpc_flood_entities( entry = entity_registry.async_get(entity_id) assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_rpc_flood_cable_unplugged( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC flood cable unplugged entity.""" + await init_integration(hass, 4) + + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_kitchen_cable_unplugged" + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + status = deepcopy(mock_rpc_device.status) + status["flood:0"]["errors"] = ["cable_unplugged"] + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + +async def test_rpc_presence_component( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC binary sensor entity for presence component.""" + config = deepcopy(mock_rpc_device.config) + config["presence"] = {"enable": True} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["presence"] = {"num_objects": 2} + monkeypatch.setattr(mock_rpc_device, "status", status) + + mock_config_entry = await init_integration(hass, 4) + + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_occupancy" + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-presence-presence_num_objects" + + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "presence", "num_objects", 0) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + config = deepcopy(mock_rpc_device.config) + config["presence"] = {"enable": False} + monkeypatch.setattr(mock_rpc_device, "config", config) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + +async def test_rpc_presencezone_component( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC binary sensor entity for presencezone component.""" + config = deepcopy(mock_rpc_device.config) + config["presencezone:200"] = {"name": "Main zone", "enable": True} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["presencezone:200"] = {"value": True, "num_objects": 3} + monkeypatch.setattr(mock_rpc_device, "status", status) + + mock_config_entry = await init_integration(hass, 4) + + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_main_zone_occupancy" + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-presencezone:200-presencezone_state" + + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "presencezone:200", "value", False + ) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + config = deepcopy(mock_rpc_device.config) + config["presencezone:200"] = {"enable": False} + monkeypatch.setattr(mock_rpc_device, "config", config) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 8d355098463..f6a3df0bb48 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -1,5 +1,6 @@ """Tests for Shelly button platform.""" +from copy import deepcopy from unittest.mock import Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3 @@ -10,12 +11,20 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration +from . import init_integration, patch_platforms, register_device, register_entity + + +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.BUTTON]): + yield async def test_block_button( @@ -31,7 +40,7 @@ async def test_block_button( assert state.state == STATE_UNKNOWN assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC_reboot" + assert entry.unique_id == "123456789ABC-reboot" await hass.services.async_call( BUTTON_DOMAIN, @@ -134,9 +143,9 @@ async def test_rpc_button_reauth_error( @pytest.mark.parametrize( ("gen", "old_unique_id", "new_unique_id", "migration"), [ - (2, "test_name_reboot", "123456789ABC_reboot", True), - (1, "test_name_reboot", "123456789ABC_reboot", True), - (2, "123456789ABC_reboot", "123456789ABC_reboot", False), + (2, "123456789ABC_reboot", "123456789ABC-reboot", True), + (1, "123456789ABC_reboot", "123456789ABC-reboot", True), + (2, "123456789ABC-reboot", "123456789ABC-reboot", False), ], ) async def test_migrate_unique_id( @@ -278,3 +287,133 @@ async def test_rpc_blu_trv_button_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_rpc_device_virtual_button( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test a virtual button for RPC device.""" + config = deepcopy(mock_rpc_device.config) + config["button:200"] = { + "name": "Button", + "meta": {"ui": {"view": "button"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["button:200"] = {"value": None} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + entity_id = "button.test_name_button" + + assert (state := hass.states.get(entity_id)) + assert state == snapshot(name=f"{entity_id}-state") + + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot(name=f"{entity_id}-entry") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_rpc_device.button_trigger.assert_called_once_with(200, "single_push") + + +async def test_rpc_remove_virtual_button_when_orphaned( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Check whether the virtual button will be removed if it has been removed from the device configuration.""" + config_entry = await init_integration(hass, 3, skip_setup=True) + device_entry = register_device(device_registry, config_entry) + entity_id = register_entity( + hass, + BUTTON_DOMAIN, + "test_name_button_200", + "button:200", + config_entry, + device_id=device_entry.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entity_id) + assert not entry + + +async def test_wall_display_virtual_button( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test a Wall Display virtual button. + + Wall display does not have "meta" key in the config and defaults to "button" view. + """ + config = deepcopy(mock_rpc_device.config) + config["button:200"] = {"name": "Button"} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["button:200"] = {"value": None} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + entity_id = "button.test_name_button" + + assert (state := hass.states.get(entity_id)) + assert state == snapshot(name=f"{entity_id}-state") + + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot(name=f"{entity_id}-entry") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_rpc_device.button_trigger.assert_called_once_with(200, "single_push") + + +async def test_migrate_unique_id_blu_trv( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test migration of unique_id for BLU TRV button.""" + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3, skip_setup=True) + + old_unique_id = "f8:44:77:25:f0:dd_calibrate" + + entity = entity_registry.async_get_or_create( + suggested_object_id="trv_name_calibrate", + disabled_by=None, + domain=BUTTON_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get("button.trv_name_calibrate") + assert entity_entry + assert entity_entry.unique_id == "F8447725F0DD-blutrv:200-calibrate" + + assert "Migrating unique_id for button.trv_name_calibrate" in caplog.text diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index c19bd916fed..54cf44c6155 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -16,18 +16,28 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_PRESET_MODE, DOMAIN as CLIMATE_DOMAIN, + FAN_LOW, PRESET_NONE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.humidifier import ATTR_HUMIDITY +from homeassistant.components.shelly.climate import PRESET_FROST_PROTECTION +from homeassistant.components.shelly.const import ( + DOMAIN, + MODEL_LINKEDGO_ST802_THERMOSTAT, + MODEL_LINKEDGO_ST1820_THERMOSTAT, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -35,6 +45,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -43,10 +54,20 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import MOCK_MAC, init_integration, register_device, register_entity +from . import ( + MOCK_MAC, + init_integration, + patch_platforms, + register_device, + register_entity, +) from .conftest import MOCK_STATUS_COAP -from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data +from tests.common import ( + async_load_json_object_fixture, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) SENSOR_BLOCK_ID = 3 DEVICE_BLOCK_ID = 4 @@ -55,6 +76,13 @@ GAS_VALVE_BLOCK_ID = 6 ENTITY_ID = f"{CLIMATE_DOMAIN}.test_name" +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.CLIMATE, Platform.SWITCH]): + yield + + async def test_climate_hvac_mode( hass: HomeAssistant, mock_block_device: Mock, @@ -911,3 +939,184 @@ async def test_blu_trv_set_target_temp_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_rpc_linkedgo_st802_thermostat( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test LINKEDGO ST802 thermostat climate.""" + entity_id = "climate.test_name" + + device_fixture = await async_load_json_object_fixture( + hass, "st802_gen3.json", DOMAIN + ) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, 3, model=MODEL_LINKEDGO_ST802_THERMOSTAT) + + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") + + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") + + # Test HVAC mode cool + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["enum:201"], "value", "cool") + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(201, True) + mock_rpc_device.enum_set.assert_called_once_with(201, "cool") + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.COOL + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 25}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 25) + mock_rpc_device.mock_update() + + mock_rpc_device.number_set.assert_called_once_with(203, 25.0) + assert (state := hass.states.get(entity_id)) + assert state.attributes.get(ATTR_TEMPERATURE) == 25 + + # Test set humidity + mock_rpc_device.number_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: entity_id, ATTR_HUMIDITY: 66}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["number:202"], "value", 66) + mock_rpc_device.mock_update() + + mock_rpc_device.number_set.assert_called_once_with(202, 66.0) + assert (state := hass.states.get(entity_id)) + assert state.attributes.get(ATTR_HUMIDITY) == 66 + + # Anti-Freeze preset mode + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_FROST_PROTECTION}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True) + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(200, True) + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_FROST_PROTECTION + + # Test set fan mode + mock_rpc_device.enum_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["enum:200"], "value", "low") + mock_rpc_device.mock_update() + + mock_rpc_device.enum_set.assert_called_once_with(200, "low") + assert (state := hass.states.get(entity_id)) + assert state.attributes.get(ATTR_FAN_MODE) == FAN_LOW + + # Test HVAC mode off + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:201"], "value", False) + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(201, False) + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + + +async def test_rpc_linkedgo_st1820_thermostat( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test LINKEDGO ST1820 thermostat climate.""" + entity_id = "climate.test_name" + + device_fixture = await async_load_json_object_fixture( + hass, "st1820_gen3.json", DOMAIN + ) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, 3, model=MODEL_LINKEDGO_ST1820_THERMOSTAT) + + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") + + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 25}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["number:202"], "value", 25) + mock_rpc_device.mock_update() + + mock_rpc_device.number_set.assert_called_once_with(202, 25.0) + assert (state := hass.states.get(entity_id)) + assert state.attributes.get(ATTR_TEMPERATURE) == 25 + + # Anti-Freeze preset mode + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_FROST_PROTECTION}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True) + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(200, True) + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_FROST_PROTECTION + + # Test HVAC mode off + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:202"], "value", False) + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(202, False) + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 3282756fe28..a3bab79e99d 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -65,6 +65,15 @@ DISCOVERY_INFO_WITH_MAC = ZeroconfServiceInfo( properties={ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, type="mock_type", ) +DISCOVERY_INFO_WRONG_NAME = ZeroconfServiceInfo( + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], + hostname="mock_hostname", + name="Shelly Plus 2PM [DDEEFF]", + port=None, + properties={ATTR_PROPERTIES_ID: "shelly2pm-AABBCCDDEEFF"}, + type="mock_type", +) @pytest.mark.parametrize( @@ -1751,3 +1760,53 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ipv6_not_supported" + + +async def test_zeroconf_wrong_device_name( + hass: HomeAssistant, + mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, +) -> None: + """Test zeroconf discovery with mismatched device name.""" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "test-mac", + "model": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + }, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO_WRONG_NAME, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert context["title_placeholders"]["name"] == "Shelly Plus 2PM [DDEEFF]" + assert context["confirm_only"] is True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, + } + assert result["result"].unique_id == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index ff61eda626f..e4549d9c4a0 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -553,6 +553,57 @@ async def test_rpc_click_event( } +async def test_rpc_ignore_virtual_click_event( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_rpc_device: Mock, + events: list[Event], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC virtual click events are ignored as they are triggered by the integration.""" + await init_integration(hass, 2) + + # Generate a virtual button event + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "button:200", + "id": 200, + "event": "single_push", + "ts": 1757358109.89, + } + ], + "ts": 757358109.89, + }, + ) + await hass.async_block_till_done() + + assert len(events) == 0 + + # Generate valid event + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "data": [], + "event": "single_push", + "id": 0, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + + async def test_rpc_update_entry_sleep_period( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 4f8e8a7650d..637adaed225 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -3,6 +3,7 @@ from copy import deepcopy from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.cover import ( @@ -21,15 +22,28 @@ from homeassistant.components.cover import ( SERVICE_STOP_COVER_TILT, CoverState, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.shelly.const import RPC_COVER_UPDATE_TIME_SEC +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, mutate_rpc_device_status +from . import ( + init_integration, + mock_polling_rpc_update, + mutate_rpc_device_status, + patch_platforms, +) ROLLER_BLOCK_ID = 1 +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.COVER]): + yield + + async def test_block_device_services( hass: HomeAssistant, mock_block_device: Mock, @@ -280,3 +294,138 @@ async def test_rpc_cover_tilt( assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 + + +async def test_update_position_closing( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test update_position while the cover is closing.""" + entity_id = "cover.test_name_test_cover_0" + await init_integration(hass, 2) + + # Set initial state to closing + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "state", "closing" + ) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "current_pos", 40) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSING + assert state.attributes[ATTR_CURRENT_POSITION] == 40 + + # Simulate position decrement + async def simulated_update(*args, **kwargs): + pos = mock_rpc_device.status["cover:0"]["current_pos"] + if pos > 0: + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "current_pos", pos - 10 + ) + else: + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "current_pos", 0 + ) + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "state", "closed" + ) + + # Patching the mock update_status method + monkeypatch.setattr(mock_rpc_device, "update_status", simulated_update) + + # Simulate position updates during closing + for position in range(40, -1, -10): + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_CURRENT_POSITION] == position + assert state.state == CoverState.CLOSING + await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) + + # Final state should be closed + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + + +async def test_update_position_opening( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test update_position while the cover is opening.""" + entity_id = "cover.test_name_test_cover_0" + await init_integration(hass, 2) + + # Set initial state to opening at 60 + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "state", "opening" + ) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "current_pos", 60) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPENING + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + # Simulate position increment + async def simulated_update(*args, **kwargs): + pos = mock_rpc_device.status["cover:0"]["current_pos"] + if pos < 100: + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "current_pos", pos + 10 + ) + else: + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "current_pos", 100 + ) + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "state", "open" + ) + + # Patching the mock update_status method + monkeypatch.setattr(mock_rpc_device, "update_status", simulated_update) + + # Check position updates during opening + for position in range(60, 101, 10): + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_CURRENT_POSITION] == position + assert state.state == CoverState.OPENING + await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) + + # Final state should be open + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + +async def test_update_position_no_movement( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test update_position when the cover is not moving.""" + entity_id = "cover.test_name_test_cover_0" + await init_integration(hass, 2) + + # Set initial state to open + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "open") + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "current_pos", 100 + ) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + # Call update_position and ensure no changes occur + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py index b1703ea03e9..1e2f8088618 100644 --- a/tests/components/shelly/test_devices.py +++ b/tests/components/shelly/test_devices.py @@ -2,7 +2,12 @@ from unittest.mock import Mock -from aioshelly.const import MODEL_2PM_G3, MODEL_PRO_EM3 +from aioshelly.const import ( + MODEL_2PM_G3, + MODEL_BLU_GATEWAY_G3, + MODEL_PRO_EM3, + MODEL_WALL_DISPLAY_XL, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -510,3 +515,48 @@ async def test_block_channel_with_name( device_entry = device_registry.async_get(entry.device_id) assert device_entry assert device_entry.name == "Test name" + + +async def test_blu_trv_device_info( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, +) -> None: + """Test BLU TRV device info.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + entry = entity_registry.async_get("climate.trv_name") + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "TRV-Name" + assert device_entry.model_id == "SBTR-001AEU" + assert device_entry.sw_version == "v1.2.10" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_wall_display_xl( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + monkeypatch: pytest.MonkeyPatch, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Wall Display XL.""" + device_fixture = await async_load_json_object_fixture( + hass, "wall_display_xl.json", DOMAIN + ) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await force_uptime_value(hass, freezer) + + config_entry = await init_integration(hass, gen=2, model=MODEL_WALL_DISPLAY_XL) + + await snapshot_device_entities( + hass, entity_registry, snapshot, config_entry.entry_id + ) diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index 520233eaf60..c530f30beb9 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -14,15 +14,27 @@ from homeassistant.components.event import ( DOMAIN as EVENT_DOMAIN, EventDeviceClass, ) -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, inject_rpc_device_event, register_entity +from . import ( + init_integration, + inject_rpc_device_event, + patch_platforms, + register_entity, +) DEVICE_BLOCK_ID = 4 +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.EVENT]): + yield + + async def test_rpc_button( hass: HomeAssistant, mock_rpc_device: Mock, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 703df09bb61..8457354351f 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -41,7 +41,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry, format_mac from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component -from . import MOCK_MAC, init_integration, mutate_rpc_device_status +from . import MOCK_MAC, init_integration, mutate_rpc_device_status, register_sub_device async def test_custom_coap_port( @@ -653,3 +653,30 @@ async def test_blu_trv_stale_device_removal( assert hass.states.get(trv_201_entity_id) is None assert device_registry.async_get(trv_201_entry.device_id) is None + + +async def test_empty_device_removal( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Test removal of empty devices due to device configuration changes.""" + config_entry = await init_integration(hass, 3) + + # create empty sub-device + sub_device_entry = register_sub_device( + device_registry, + config_entry, + "boolean:201-boolean", + ) + + # verify that the sub-device is created + assert device_registry.async_get(sub_device_entry.id) is not None + + # device config change triggers a reload + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # verify that the empty sub-device is removed + assert device_registry.async_get(sub_device_entry.id) is None diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 9c79cf5d988..cf4ffbc2f66 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -38,6 +38,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry @@ -47,6 +48,7 @@ from . import ( get_entity, init_integration, mutate_rpc_device_status, + patch_platforms, register_device, register_entity, ) @@ -57,6 +59,13 @@ LIGHT_BLOCK_ID = 2 SHELLY_PLUS_RGBW_CHANNELS = 4 +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.LIGHT]): + yield + + async def test_block_device_rgbw_bulb( hass: HomeAssistant, mock_block_device: Mock, @@ -459,6 +468,7 @@ async def test_rpc_device_switch_type_lights_mode( monkeypatch.setitem( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") await init_integration(hass, 2) await hass.services.async_call( @@ -926,3 +936,29 @@ async def test_rpc_remove_cct_light( # there is no cct:0 in the status, so the CCT light entity should be removed assert get_entity(hass, LIGHT_DOMAIN, "cct:0") is None + + +async def test_rpc_cct_light_without_ct_range( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC CCT light without ct_range in the light config.""" + entity_id = f"{LIGHT_DOMAIN}.living_room_lamp" + + config = deepcopy(mock_rpc_device.config) + config["cct:0"] = {"id": 0, "name": "Living room lamp"} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["cct:0"] = {"id": 0, "output": False, "brightness": 77, "ct": 3666} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + # default values from constants are 2700 and 6500 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index e33b04721cc..c7230821772 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -20,19 +20,31 @@ from homeassistant.components.number import ( ) from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, register_device, register_entity +from . import init_integration, patch_platforms, register_device, register_entity from tests.common import mock_restore_cache_with_extra_data DEVICE_BLOCK_ID = 4 +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.NUMBER]): + yield + + async def test_block_number_update( hass: HomeAssistant, mock_block_device: Mock, @@ -340,6 +352,7 @@ async def test_rpc_device_virtual_number( assert state.state == "56.7" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_number_when_mode_label( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index 8dfd59c49ba..d5d01402877 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -2,6 +2,7 @@ from unittest.mock import Mock +from aioshelly.const import MODEL_WALL_DISPLAY from aioshelly.exceptions import DeviceConnectionError, RpcCallError import pytest @@ -10,6 +11,7 @@ from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, DOMAIN, OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, + WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID, BLEScannerMode, ) from homeassistant.core import HomeAssistant @@ -211,3 +213,35 @@ async def test_outbound_websocket_incorrectly_enabled_issue_exc( assert issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 1 + + +async def test_wall_display_unsupported_firmware_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issues handling for Wall Display with unsupported firmware.""" + issue_id = WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # The default fw version in tests is 1.0.0, the repair issue should be created. + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index bb68edd1961..eefd84d40eb 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -14,13 +14,20 @@ from homeassistant.components.select import ( ) from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, register_device, register_entity +from . import init_integration, patch_platforms, register_device, register_entity + + +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.SELECT]): + yield @pytest.mark.parametrize( @@ -92,6 +99,7 @@ async def test_rpc_device_virtual_enum( assert state.state == "Title 1" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_enum_when_mode_label( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 8f021c2d58a..f1f41f5c188 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -28,12 +28,14 @@ from homeassistant.const import ( PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.device_registry import DeviceRegistry @@ -46,6 +48,7 @@ from . import ( mock_polling_rpc_update, mock_rest_update, mutate_rpc_device_status, + patch_platforms, register_device, register_entity, ) @@ -57,6 +60,13 @@ SENSOR_BLOCK_ID = 3 DEVICE_BLOCK_ID = 4 +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.SENSOR]): + yield + + async def test_block_sensor( hass: HomeAssistant, mock_block_device: Mock, @@ -697,27 +707,19 @@ async def test_rpc_energy_meter_1_sensors( assert (entry := entity_registry.async_get("sensor.test_name_energy_meter_1_power")) assert entry.unique_id == "123456789ABC-em1:1-power_em1" - assert ( - state := hass.states.get("sensor.test_name_energy_meter_0_total_active_energy") - ) + assert (state := hass.states.get("sensor.test_name_energy_meter_0_energy")) assert state.state == "123.4564" assert ( - entry := entity_registry.async_get( - "sensor.test_name_energy_meter_0_total_active_energy" - ) + entry := entity_registry.async_get("sensor.test_name_energy_meter_0_energy") ) assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" - assert ( - state := hass.states.get("sensor.test_name_energy_meter_1_total_active_energy") - ) + assert (state := hass.states.get("sensor.test_name_energy_meter_1_energy")) assert state.state == "987.6543" assert ( - entry := entity_registry.async_get( - "sensor.test_name_energy_meter_1_total_active_energy" - ) + entry := entity_registry.async_get("sensor.test_name_energy_meter_1_energy") ) assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" @@ -1069,7 +1071,7 @@ async def test_rpc_device_virtual_text_sensor( assert state.state == "lorem ipsum" assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-text:203-text" + assert entry.unique_id == "123456789ABC-text:203-text_generic" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() @@ -1077,6 +1079,53 @@ async def test_rpc_device_virtual_text_sensor( assert state.state == "dolor sit amet" +@pytest.mark.parametrize( + ("old_id", "new_id", "device_class"), + [ + ("enum", "enum_generic", SensorDeviceClass.ENUM), + ("number", "number_generic", None), + ("number", "number_current_humidity", SensorDeviceClass.HUMIDITY), + ("number", "number_current_temperature", SensorDeviceClass.TEMPERATURE), + ("text", "text_generic", None), + ], +) +async def test_migrate_unique_id_virtual_components_roles( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + caplog: pytest.LogCaptureFixture, + old_id: str, + new_id: str, + device_class: SensorDeviceClass | None, +) -> None: + """Test migration of unique_id for virtual components to include role.""" + entry = await init_integration(hass, 3, skip_setup=True) + unique_base = f"{MOCK_MAC}-{old_id}:200" + old_unique_id = f"{unique_base}-{old_id}" + new_unique_id = f"{unique_base}-{new_id}" + + entity = entity_registry.async_get_or_create( + suggested_object_id="test_name_test_sensor", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + original_device_class=device_class, + ) + assert entity.unique_id == old_unique_id + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get("sensor.test_name_test_sensor") + assert entity_entry + assert entity_entry.unique_id == new_unique_id + + assert "Migrating unique_id for sensor.test_name_test_sensor" in caplog.text + + +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_text_virtual_sensor_when_mode_field( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -1099,7 +1148,7 @@ async def test_rpc_remove_text_virtual_sensor_when_mode_field( hass, SENSOR_DOMAIN, "test_name_text_200", - "text:200-text", + "text:200-text_generic", config_entry, device_id=device_entry.id, ) @@ -1123,7 +1172,7 @@ async def test_rpc_remove_text_virtual_sensor_when_orphaned( hass, SENSOR_DOMAIN, "test_name_text_200", - "text:200-text", + "text:200-text_generic", config_entry, device_id=device_entry.id, ) @@ -1138,6 +1187,7 @@ async def test_rpc_remove_text_virtual_sensor_when_orphaned( ("name", "entity_id", "original_unit", "expected_unit"), [ ("Virtual number sensor", "sensor.test_name_virtual_number_sensor", "W", "W"), + ("Unit map", "sensor.test_name_unit_map", "m3/min", "m³/min"), (None, "sensor.test_name_number_203", "", None), ], ) @@ -1172,7 +1222,7 @@ async def test_rpc_device_virtual_number_sensor( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-number:203-number" + assert entry.unique_id == "123456789ABC-number:203-number_generic" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) mock_rpc_device.mock_update() @@ -1180,6 +1230,7 @@ async def test_rpc_device_virtual_number_sensor( assert state.state == "56.7" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_number_virtual_sensor_when_mode_field( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -1207,7 +1258,7 @@ async def test_rpc_remove_number_virtual_sensor_when_mode_field( hass, SENSOR_DOMAIN, "test_name_number_200", - "number:200-number", + "number:200-number_generic", config_entry, device_id=device_entry.id, ) @@ -1231,7 +1282,7 @@ async def test_rpc_remove_number_virtual_sensor_when_orphaned( hass, SENSOR_DOMAIN, "test_name_number_200", - "number:200-number", + "number:200-number_generic", config_entry, device_id=device_entry.id, ) @@ -1285,7 +1336,7 @@ async def test_rpc_device_virtual_enum_sensor( assert state.attributes.get(ATTR_OPTIONS) == ["Title 1", "two", "three"] assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-enum:203-enum" + assert entry.unique_id == "123456789ABC-enum:203-enum_generic" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "two") mock_rpc_device.mock_update() @@ -1293,6 +1344,7 @@ async def test_rpc_device_virtual_enum_sensor( assert state.state == "two" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -1324,7 +1376,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( hass, SENSOR_DOMAIN, "test_name_enum_200", - "enum:200-enum", + "enum:200-enum_generic", config_entry, device_id=device_entry.id, ) @@ -1348,7 +1400,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_orphaned( hass, SENSOR_DOMAIN, "test_name_enum_200", - "enum:200-enum", + "enum:200-enum_generic", config_entry, device_id=device_entry.id, ) @@ -1511,8 +1563,10 @@ async def test_rpc_device_virtual_number_sensor_with_device_class( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, ) -> None: """Test a virtual number sensor with device class for RPC device.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_current_humidity" config = deepcopy(mock_rpc_device.config) config["number:203"] = { "name": "Current humidity", @@ -1529,12 +1583,42 @@ async def test_rpc_device_virtual_number_sensor_with_device_class( await init_integration(hass, 3) - assert (state := hass.states.get("sensor.test_name_current_humidity")) + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-number:203-number_current_humidity" + + assert (state := hass.states.get(entity_id)) assert state.state == "34" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY +async def test_rpc_object_role_sensor( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test object role based sensor.""" + config = deepcopy(mock_rpc_device.config) + config["object:200"] = { + "name": "Water consumption", + "meta": {"ui": {"unit": "m3"}}, + "role": "water_consumption", + } + + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["object:200"] = {"value": {"counter": {"total": 5.4}}} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + assert (state := hass.states.get("sensor.test_name_water_consumption")) + assert state.state == "5.4" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_switch_energy_sensors( hass: HomeAssistant, @@ -1557,7 +1641,7 @@ async def test_rpc_switch_energy_sensors( monkeypatch.setattr(mock_rpc_device, "status", status) await init_integration(hass, 3) - for entity in ("energy", "returned_energy"): + for entity in ("energy", "energy_returned", "energy_consumed"): entity_id = f"{SENSOR_DOMAIN}.test_name_test_switch_0_{entity}" state = hass.states.get(entity_id) @@ -1568,12 +1652,12 @@ async def test_rpc_switch_energy_sensors( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_rpc_switch_no_returned_energy_sensor( +async def test_rpc_switch_no_energy_returned_sensor( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test switch component without returned energy sensor.""" + """Test switch component without energy returned sensor.""" status = { "sys": {}, "switch:0": { @@ -1586,7 +1670,76 @@ async def test_rpc_switch_no_returned_energy_sensor( monkeypatch.setattr(mock_rpc_device, "status", status) await init_integration(hass, 3) - assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None + assert hass.states.get("sensor.test_name_test_switch_0_energy_returned") is None + assert hass.states.get("sensor.test_name_test_switch_0_energy_consumed") is None + + +async def test_rpc_shelly_ev_sensors( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test Shelly EV sensors.""" + config = deepcopy(mock_rpc_device.config) + config["number:200"] = { + "name": "Charger state", + "meta": { + "ui": { + "titles": { + "charger_charging": "Charging", + "charger_end": "End", + "charger_fault": "Fault", + "charger_free": "Free", + "charger_free_fault": "Free fault", + "charger_insert": "Insert", + "charger_pause": "Pause", + "charger_wait": "Wait", + }, + "view": "label", + } + }, + "options": [ + "charger_free", + "charger_insert", + "charger_free_fault", + "charger_wait", + "charger_charging", + "charger_pause", + "charger_end", + "charger_fault", + ], + "role": "work_state", + } + config["number:201"] = { + "name": "Session energy", + "meta": {"ui": {"unit": "Wh", "view": "label"}}, + "role": "energy_charge", + } + config["number:202"] = { + "name": "Session duration", + "meta": {"ui": {"unit": "min", "view": "label"}}, + "role": "time_charge", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:200"] = {"value": "charger_charging"} + status["number:201"] = {"value": 5000} + status["number:202"] = {"value": 60} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + for entity in ("charger_state", "session_energy", "session_duration"): + entity_id = f"{SENSOR_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") async def test_block_friendly_name_sleeping_sensor( @@ -1629,3 +1782,162 @@ async def test_block_friendly_name_sleeping_sensor( assert (state := hass.states.get(entity.entity_id)) assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature" + + +async def test_rpc_presence_component( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC sensor entity for presence component.""" + config = deepcopy(mock_rpc_device.config) + config["presence"] = {"enable": True} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["presence"] = {"num_objects": 2} + monkeypatch.setattr(mock_rpc_device, "status", status) + + mock_config_entry = await init_integration(hass, 4) + + entity_id = f"{SENSOR_DOMAIN}.test_name_detected_objects" + + assert (state := hass.states.get(entity_id)) + assert state.state == "2" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-presence-presence_num_objects" + + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "presence", "num_objects", 0) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == "0" + + config = deepcopy(mock_rpc_device.config) + config["presence"] = {"enable": False} + monkeypatch.setattr(mock_rpc_device, "config", config) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + +async def test_rpc_presencezone_component( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC sensor entity for presencezone component.""" + config = deepcopy(mock_rpc_device.config) + config["presencezone:201"] = {"name": "Other zone", "enable": True} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["presencezone:201"] = {"state": True, "num_objects": 3} + monkeypatch.setattr(mock_rpc_device, "status", status) + + mock_config_entry = await init_integration(hass, 4) + + entity_id = f"{SENSOR_DOMAIN}.test_name_other_zone_detected_objects" + + assert (state := hass.states.get(entity_id)) + assert state.state == "3" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-presencezone:201-presencezone_num_objects" + + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "presencezone:201", "num_objects", 2 + ) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == "2" + + config = deepcopy(mock_rpc_device.config) + config["presencezone:201"] = {"enable": False} + monkeypatch.setattr(mock_rpc_device, "config", config) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_pm1_energy_consumed_sensor( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test energy sensors for switch component.""" + status = { + "sys": {}, + "pm1:0": { + "id": 0, + "voltage": 235.0, + "current": 0.957, + "apower": -220.3, + "freq": 50.0, + "aenergy": {"total": 3000.000}, + "ret_aenergy": {"total": 1000.000}, + }, + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + assert (state := hass.states.get(f"{SENSOR_DOMAIN}.test_name_energy")) + assert state.state == "3.0" + + assert (state := hass.states.get(f"{SENSOR_DOMAIN}.test_name_energy_returned")) + assert state.state == "1.0" + + entity_id = f"{SENSOR_DOMAIN}.test_name_energy_consumed" + # energy consumed = energy - energy returned + assert (state := hass.states.get(entity_id)) + assert state.state == "2.0" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-pm1:0-consumed_energy_pm1" + + +@pytest.mark.parametrize(("key"), ["aenergy", "ret_aenergy"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_pm1_energy_consumed_sensor_non_float_value( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + key: str, +) -> None: + """Test energy sensors for switch component.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_energy_consumed" + status = { + "sys": {}, + "pm1:0": { + "id": 0, + "voltage": 235.0, + "current": 0.957, + "apower": -220.3, + "freq": 50.0, + "aenergy": {"total": 3000.000}, + "ret_aenergy": {"total": 1000.000}, + }, + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + assert (state := hass.states.get(entity_id)) + assert state.state == "2.0" + + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "pm1:0", key, {"total": None} + ) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index f1866d83e2a..39fc001cbed 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -25,6 +25,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -34,8 +35,10 @@ from homeassistant.helpers.entity_registry import EntityRegistry from . import ( init_integration, inject_rpc_device_event, + patch_platforms, register_device, register_entity, + register_sub_device, ) from tests.common import async_fire_time_changed, mock_restore_cache @@ -47,6 +50,15 @@ GAS_VALVE_BLOCK_ID = 6 MOTION_BLOCK_ID = 3 +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms( + [Platform.SWITCH, Platform.CLIMATE, Platform.VALVE, Platform.LIGHT] + ): + yield + + async def test_block_device_services( hass: HomeAssistant, mock_block_device: Mock ) -> None: @@ -658,6 +670,7 @@ async def test_rpc_device_virtual_switch( assert state.state == STATE_ON +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_device_virtual_binary_sensor( hass: HomeAssistant, mock_rpc_device: Mock, @@ -679,6 +692,7 @@ async def test_rpc_device_virtual_binary_sensor( assert hass.states.get(entity_id) is None +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_switch_when_mode_label( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -720,8 +734,10 @@ async def test_rpc_remove_virtual_switch_when_orphaned( ) -> None: """Check whether the virtual switch will be removed if it has been removed from the device configuration.""" config_entry = await init_integration(hass, 3, skip_setup=True) + + # create orphaned entity on main device device_entry = register_device(device_registry, config_entry) - entity_id = register_entity( + entity_id1 = register_entity( hass, SWITCH_DOMAIN, "test_name_boolean_200", @@ -730,10 +746,29 @@ async def test_rpc_remove_virtual_switch_when_orphaned( device_id=device_entry.id, ) + # create orphaned entity on sub device + sub_device_entry = register_sub_device( + device_registry, + config_entry, + "boolean:201-boolean", + ) + entity_id2 = register_entity( + hass, + SWITCH_DOMAIN, + "boolean_201", + "boolean:201-boolean", + config_entry, + device_id=sub_device_entry.id, + ) + + assert entity_registry.async_get(entity_id1) is not None + assert entity_registry.async_get(entity_id2) is not None + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert entity_registry.async_get(entity_id) is None + assert entity_registry.async_get(entity_id1) is None + assert entity_registry.async_get(entity_id2) is None @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/shelly/test_text.py b/tests/components/shelly/test_text.py index 165272313cb..59c434213b1 100644 --- a/tests/components/shelly/test_text.py +++ b/tests/components/shelly/test_text.py @@ -13,13 +13,20 @@ from homeassistant.components.text import ( SERVICE_SET_VALUE, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, register_device, register_entity +from . import init_integration, patch_platforms, register_device, register_entity + + +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.TEXT]): + yield @pytest.mark.parametrize( @@ -77,6 +84,7 @@ async def test_rpc_device_virtual_text( assert state.state == "sed do eiusmod" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_text_when_mode_label( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 51016f0cdaa..8007ecc3615 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -39,6 +40,7 @@ from . import ( init_integration, inject_rpc_device_event, mock_rest_update, + patch_platforms, register_device, register_entity, ) @@ -46,6 +48,13 @@ from . import ( from tests.common import mock_restore_cache +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.UPDATE]): + yield + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_block_update( hass: HomeAssistant, diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 0cdd1640e65..ec5bd411ac3 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -34,6 +34,7 @@ from homeassistant.components.shelly.utils import ( get_rpc_channel_name, get_rpc_input_triggers, is_block_momentary_input, + mac_address_from_name, ) from homeassistant.util import dt as dt_util @@ -327,3 +328,17 @@ def test_get_release_url( def test_get_host(host: str, expected: str) -> None: """Test get_host function.""" assert get_host(host) == expected + + +@pytest.mark.parametrize( + ("name", "result"), + [ + ("shelly1pm-AABBCCDDEEFF", "AABBCCDDEEFF"), + ("Shelly Plus 1 [DDEEFF]", None), + ("S11-Schlafzimmer", None), + ("22-Kueche-links", None), + ], +) +def test_mac_address_from_name(name: str, result: str | None) -> None: + """Test mac_address_from_name() function.""" + assert mac_address_from_name(name) == result diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index 7bf9e3b5f1a..301de83c2d8 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -1,20 +1,43 @@ """Tests for Shelly valve platform.""" +from copy import deepcopy from unittest.mock import Mock from aioshelly.const import MODEL_GAS import pytest -from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE +from homeassistant.components.shelly.const import ( + MODEL_FRANKEVER_WATER_VALVE, + MODEL_NEO_WATER_VALVE, +) +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as VALVE_DOMAIN, + ValveState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration +from . import init_integration, patch_platforms GAS_VALVE_BLOCK_ID = 6 +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.VALVE]): + yield + + async def test_block_device_gas_valve( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -64,3 +87,157 @@ async def test_block_device_gas_valve( assert (state := hass.states.get(entity_id)) assert state.state == ValveState.CLOSED + + +async def test_rpc_water_valve( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC device Shelly Water Valve.""" + config = deepcopy(mock_rpc_device.config) + config["number:200"] = { + "name": "Position", + "min": 0, + "max": 100, + "meta": {"ui": {"step": 10, "view": "slider", "unit": "%"}}, + "role": "position", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:200"] = {"value": 0} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3, model=MODEL_FRANKEVER_WATER_VALVE) + entity_id = "valve.test_name" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-number:200-water_valve" + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED + + # Open valve + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_rpc_device.number_set.assert_called_once_with(200, 100) + + status["number:200"] = {"value": 100} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.OPEN + + # Close valve + mock_rpc_device.number_set.reset_mock() + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_rpc_device.number_set.assert_called_once_with(200, 0) + + status["number:200"] = {"value": 0} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED + + # Set valve position to 50% + mock_rpc_device.number_set.reset_mock() + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + + mock_rpc_device.number_set.assert_called_once_with(200, 50) + + status["number:200"] = {"value": 50} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.OPEN + assert state.attributes.get(ATTR_CURRENT_POSITION) == 50 + + +async def test_rpc_neo_water_valve( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC device Shelly NEO Water Valve.""" + config = deepcopy(mock_rpc_device.config) + config["boolean:200"] = { + "name": "State", + "meta": {"ui": {"view": "toggle"}}, + "role": "state", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["boolean:200"] = {"value": False} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3, model=MODEL_NEO_WATER_VALVE) + entity_id = "valve.test_name" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-boolean:200-neo_water_valve" + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED + + # Open valve + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_rpc_device.boolean_set.assert_called_once_with(200, True) + + status["boolean:200"] = {"value": True} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.OPEN + + # Close valve + mock_rpc_device.boolean_set.reset_mock() + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_rpc_device.boolean_set.assert_called_once_with(200, False) + + status["boolean:200"] = {"value": False} + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED diff --git a/tests/components/signal_messenger/test_notify.py b/tests/components/signal_messenger/test_notify.py index d0085fd6e21..a6489a60d18 100644 --- a/tests/components/signal_messenger/test_notify.py +++ b/tests/components/signal_messenger/test_notify.py @@ -64,6 +64,27 @@ def test_send_message( assert_sending_requests(signal_requests_mock) +def test_send_message_with_custom_recipients( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test send message with custom recipients.""" + signal_requests_mock = signal_requests_mock_factory() + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + signal_notification_service.send_message( + MESSAGE, target=["+49111111111", "+49222222222"] + ) + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 2 + assert_sending_requests( + signal_requests_mock, recipients=["+49111111111", "+49222222222"] + ) + + def test_send_message_styled( signal_notification_service: SignalNotificationService, signal_requests_mock_factory: Mocker, @@ -416,7 +437,9 @@ def test_get_attachments_with_verify_set_garbage( def assert_sending_requests( - signal_requests_mock_factory: Mocker, attachments_num: int = 0 + signal_requests_mock_factory: Mocker, + attachments_num: int = 0, + recipients: list[str] | None = None, ) -> None: """Assert message was send with correct parameters.""" send_request = signal_requests_mock_factory.request_history[-1] @@ -425,7 +448,7 @@ def assert_sending_requests( body_request = json.loads(send_request.text) assert body_request["message"] == MESSAGE assert body_request["number"] == NUMBER_FROM - assert body_request["recipients"] == NUMBERS_TO + assert body_request["recipients"] == (recipients if recipients else NUMBERS_TO) assert len(body_request["base64_attachments"]) == attachments_num for attachment in body_request["base64_attachments"]: diff --git a/tests/components/sleep_as_android/test_sensor.py b/tests/components/sleep_as_android/test_sensor.py index 760df1e0181..f8706b29f6b 100644 --- a/tests/components/sleep_as_android/test_sensor.py +++ b/tests/components/sleep_as_android/test_sensor.py @@ -122,3 +122,45 @@ async def test_webhook_sensor( assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) assert state.state == "label" + + +async def test_webhook_sensor_alarm_unset( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test unsetting sensors if there is no next alarm.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client_no_auth() + + response = await client.post( + "/api/webhook/webhook_id", + json={ + "event": "alarm_rescheduled", + "value1": "1582719660934", + "value2": "label", + }, + ) + assert response.status == HTTPStatus.NO_CONTENT + + assert (state := hass.states.get("sensor.sleep_as_android_next_alarm")) + assert state.state == "2020-02-26T12:21:00+00:00" + + assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) + assert state.state == "label" + + response = await client.post( + "/api/webhook/webhook_id", + json={"event": "alarm_rescheduled"}, + ) + assert (state := hass.states.get("sensor.sleep_as_android_next_alarm")) + assert state.state == STATE_UNKNOWN + + assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/slide_local/__init__.py b/tests/components/slide_local/__init__.py index cd7bd6cb6d1..ac12738c2fd 100644 --- a/tests/components/slide_local/__init__.py +++ b/tests/components/slide_local/__init__.py @@ -1,11 +1,13 @@ """Tests for the slide_local integration.""" +from typing import Any from unittest.mock import patch +from homeassistant.components.slide_local.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def setup_platform( @@ -19,3 +21,11 @@ async def setup_platform( await hass.async_block_till_done() return config_entry + + +def get_data() -> dict[str, Any]: + """Return the default state data. + + The coordinator mutates the returned API data, so we can't return a glocal dict. + """ + return load_json_object_fixture("slide_1.json", DOMAIN) diff --git a/tests/components/slide_local/conftest.py b/tests/components/slide_local/conftest.py index ad2734bbb64..f5c48259b12 100644 --- a/tests/components/slide_local/conftest.py +++ b/tests/components/slide_local/conftest.py @@ -8,7 +8,8 @@ import pytest from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC -from .const import HOST, SLIDE_INFO_DATA +from . import get_data +from .const import HOST from tests.common import MockConfigEntry @@ -48,7 +49,7 @@ def mock_slide_api() -> Generator[AsyncMock]: ), ): client = mock_slide_local_api.return_value - client.slide_info.return_value = SLIDE_INFO_DATA + client.slide_info.return_value = get_data() yield client diff --git a/tests/components/slide_local/const.py b/tests/components/slide_local/const.py index edf45753407..2ba097e9107 100644 --- a/tests/components/slide_local/const.py +++ b/tests/components/slide_local/const.py @@ -1,8 +1,3 @@ """Common const used across tests for slide_local.""" -from homeassistant.components.slide_local.const import DOMAIN - -from tests.common import load_json_object_fixture - HOST = "127.0.0.2" -SLIDE_INFO_DATA = load_json_object_fixture("slide_1.json", DOMAIN) diff --git a/tests/components/slide_local/snapshots/test_diagnostics.ambr b/tests/components/slide_local/snapshots/test_diagnostics.ambr index 7606c2a399b..73567ce0e20 100644 --- a/tests/components/slide_local/snapshots/test_diagnostics.ambr +++ b/tests/components/slide_local/snapshots/test_diagnostics.ambr @@ -31,7 +31,7 @@ 'curtain_type': 0, 'device_name': 'slide bedroom', 'mac': '1234567890ab', - 'pos': 0, + 'pos': 1, 'slide_id': 'slide_1234567890ab', 'state': 'open', 'touch_go': True, diff --git a/tests/components/slide_local/test_config_flow.py b/tests/components/slide_local/test_config_flow.py index b8b69d99fd8..ac5e7506bb1 100644 --- a/tests/components/slide_local/test_config_flow.py +++ b/tests/components/slide_local/test_config_flow.py @@ -18,8 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import setup_platform -from .const import HOST, SLIDE_INFO_DATA +from . import get_data, setup_platform +from .const import HOST from tests.common import MockConfigEntry @@ -82,7 +82,10 @@ async def test_user_api_1( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + mock_slide_api.slide_info.side_effect = [ + None, + get_data(), + ] result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -129,7 +132,10 @@ async def test_user_api_error( assert result["step_id"] == "user" assert result["errors"]["base"] == "unknown" - mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + mock_slide_api.slide_info.side_effect = [ + None, + get_data(), + ] result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -188,7 +194,10 @@ async def test_api_1_exceptions( assert result["errors"]["base"] == error # tests with all provided - mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + mock_slide_api.slide_info.side_effect = [ + None, + get_data(), + ] result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/slide_local/test_cover.py b/tests/components/slide_local/test_cover.py index 793f9d9513d..a2262e6c89f 100644 --- a/tests/components/slide_local/test_cover.py +++ b/tests/components/slide_local/test_cover.py @@ -20,8 +20,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_platform -from .const import SLIDE_INFO_DATA +from . import get_data, setup_platform from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -48,7 +47,9 @@ async def test_connection_error( """Test connection error.""" await setup_platform(hass, mock_config_entry, [Platform.COVER]) - mock_slide_api.slide_info.side_effect = [ClientConnectionError, SLIDE_INFO_DATA] + assert hass.states.get("cover.slide_bedroom").state == CoverState.OPEN + + mock_slide_api.slide_info.side_effect = [ClientConnectionError, get_data()] freezer.tick(delta=timedelta(minutes=1)) async_fire_time_changed(hass) @@ -69,15 +70,13 @@ async def test_state_change( mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test connection error.""" + """Test state changes.""" await setup_platform(hass, mock_config_entry, [Platform.COVER]) - mock_slide_api.slide_info.side_effect = [ - dict(SLIDE_INFO_DATA, pos=0.0), - dict(SLIDE_INFO_DATA, pos=0.4), - dict(SLIDE_INFO_DATA, pos=1.0), - dict(SLIDE_INFO_DATA, pos=0.8), - ] + mock_slide_api.slide_info.return_value = { + **get_data(), + "pos": 0.0, + } freezer.tick(delta=timedelta(minutes=1)) async_fire_time_changed(hass) @@ -85,18 +84,24 @@ async def test_state_change( assert hass.states.get("cover.slide_bedroom").state == CoverState.OPEN + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 0.4} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("cover.slide_bedroom").state == CoverState.CLOSING + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 1.0} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("cover.slide_bedroom").state == CoverState.CLOSED + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 0.8} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -171,12 +176,7 @@ async def test_set_position( await setup_platform(hass, mock_config_entry, [Platform.COVER]) - mock_slide_api.slide_info.side_effect = [ - dict(SLIDE_INFO_DATA, pos=0.0), - dict(SLIDE_INFO_DATA, pos=1.0), - dict(SLIDE_INFO_DATA, pos=1.0), - dict(SLIDE_INFO_DATA, pos=0.0), - ] + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 0.0} freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) @@ -189,6 +189,8 @@ async def test_set_position( blocking=True, ) + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 1.0} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -206,6 +208,8 @@ async def test_set_position( blocking=True, ) + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 0.0} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index f13617d64d5..a68bbba22d2 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -102,6 +102,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_ac_rac_000003", "da_ac_rac_100001", "da_ac_rac_01001", + "da_ac_cac_01001", "multipurpose_sensor", "contact_sensor", "base_electric_meter", @@ -165,6 +166,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "hw_q80r_soundbar", "gas_meter", "lumi", + "tesla_powerwall", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_cac_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_cac_01001.json new file mode 100644 index 00000000000..5aab45dd68b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_cac_01001.json @@ -0,0 +1,801 @@ +{ + "components": { + "light": { + "samsungce.airConditionerLighting": { + "supportedLightingLevels": { + "value": null + }, + "lighting": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.airConditionerLighting"], + "timestamp": "2025-07-31T13:50:35.882Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-08-19T02:01:25.709Z" + } + } + }, + "main": { + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [ + "airConditionerMode.setAirConditionerMode", + "airConditionerFanMode.setFanMode", + "custom.spiMode.setSpiMode" + ], + "timestamp": "2025-08-19T12:19:48.005Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 59, + "unit": "%", + "timestamp": "2025-08-19T12:21:58.148Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 18, + "unit": "C", + "timestamp": "2025-07-31T13:50:35.882Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2025-07-31T13:50:35.882Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": [], + "timestamp": "2025-08-19T12:19:48.005Z" + }, + "supportedAcModes": { + "value": ["aIComfort", "auto", "cool", "dry", "fan", "heat"], + "timestamp": "2025-07-31T13:50:35.882Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-08-19T12:09:54.052Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": ["off", "windFree", "longWind", "speed", "quiet", "sleep"], + "timestamp": "2025-07-31T13:50:35.882Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-08-19T02:01:25.689Z" + } + }, + "samsungce.airConditionerBeep": { + "beep": { + "value": "on", + "timestamp": "2025-08-19T12:09:54.052Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ASA-WW-TP1-24-PACCOM_14240625", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "di": { + "value": "23c6d296-4656-20d8-f6eb-2ff13e041753", + "timestamp": "2025-07-31T13:51:02.464Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-07-31T13:51:02.464Z" + }, + "n": { + "value": "Samsung System A/C", + "timestamp": "2025-07-31T13:51:02.464Z" + }, + "mnmo": { + "value": "TP1X_DA-AC-CAC-01001_0000|10257341|600201482018110B46004F3000F3A900", + "timestamp": "2025-07-31T13:51:05.891Z" + }, + "vid": { + "value": "DA-AC-CAC-01001", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "mnpv": { + "value": "SYSTEM 2.0", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "mnos": { + "value": "TizenRT 4.0", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "pi": { + "value": "23c6d296-4656-20d8-f6eb-2ff13e041753", + "timestamp": "2025-07-31T13:51:02.516Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-07-31T13:51:02.464Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.spiMode", + "custom.veryFineDustFilter", + "custom.deodorFilter", + "custom.electricHepaFilter", + "custom.periodicSensing", + "custom.doNotDisturbMode", + "samsungce.absenceDetection", + "samsungce.powerSavingWhileAway", + "airQualitySensor", + "samsungce.airQualityHealthConcern", + "odorSensor", + "dustSensor", + "veryFineDustSensor", + "samsungce.airConditionerAudioFeedback", + "custom.airConditionerOdorController" + ], + "timestamp": "2025-07-31T20:33:13.550Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25060102, + "timestamp": "2025-07-29T18:39:12.233Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "AS7", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-08-19T02:01:25.872Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": ["vertical", "fixed", "horizontal", "all"], + "timestamp": "2025-07-31T13:50:35.882Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-08-19T02:01:25.942Z" + } + }, + "custom.periodicSensing": { + "automaticExecutionSetting": { + "value": null + }, + "automaticExecutionMode": { + "value": null + }, + "supportedAutomaticExecutionSetting": { + "value": null + }, + "supportedAutomaticExecutionMode": { + "value": null + }, + "periodicSensing": { + "value": null + }, + "periodicSensingInterval": { + "value": null + }, + "lastSensingTime": { + "value": null + }, + "lastSensingLevel": { + "value": null + }, + "periodicSensingStatus": { + "value": null + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-07-31T13:50:35.882Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-07-31T13:50:35.882Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 83160, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 83160, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-08-19T02:01:25Z", + "end": "2025-08-19T12:18:52Z" + }, + "timestamp": "2025-08-19T12:18:52.404Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": ["on", "off"], + "timestamp": "2025-08-19T02:01:25.745Z" + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-08-19T02:01:25.745Z" + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": ["autoClean", "ready"], + "timestamp": "2025-08-19T02:01:25.745Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-08-19T02:01:25.745Z" + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-08-19T02:01:25.745Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-07-31T13:50:35.882Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-08-19T02:01:25.203Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-08-19T02:01:25.203Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-08-19T02:01:25.203Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-08-19T02:01:25.203Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-08-19T02:01:25.203Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "02698A240625", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "02573A24101000,FFFFFFFFFFFFFF", + "description": "Version" + }, + { + "id": "2", + "swType": "Outdoor", + "versionNumber": "02358A24073100,02579A10000200", + "description": "Version" + } + ], + "timestamp": "2025-08-19T02:01:25.872Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": "checking", + "timestamp": "2025-08-19T02:01:25.774Z" + }, + "supportedActions": { + "value": ["start", "cancel"], + "timestamp": "2025-08-19T02:01:25.774Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-08-19T02:01:25.774Z" + }, + "errors": { + "value": [], + "timestamp": "2025-08-19T02:01:25.774Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-08-19T02:01:25.774Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": 1, + "timestamp": "2025-08-19T02:01:25.620Z" + }, + "dustFilterUsage": { + "value": 4, + "timestamp": "2025-08-19T02:01:25.620Z" + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": "normal", + "timestamp": "2025-08-19T02:01:25.620Z" + }, + "dustFilterCapacity": { + "value": 1000, + "unit": "Hour", + "timestamp": "2025-08-19T02:01:25.620Z" + }, + "dustFilterResetType": { + "value": ["washable"], + "timestamp": "2025-08-19T02:01:25.620Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2025-07-29T18:39:12.442Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-07-29T18:39:12.442Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-07-29T18:39:13.323Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-08-19T02:01:26.853Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-07-29T18:39:12.442Z" + } + }, + "samsungce.airQualityHealthConcern": { + "supportedAirQualityHealthConcerns": { + "value": ["good", "normal", "poor", "veryPoor"], + "timestamp": "2025-07-29T18:39:12.442Z" + }, + "airQualityHealthConcern": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-08-19T02:01:25.702Z" + }, + "otnDUID": { + "value": "KLCDM7UUCNTY4", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-07-29T18:39:12.442Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-08-19T02:01:25.702Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-08-19T02:01:25.702Z" + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null + }, + "airConditionerOdorControllerState": { + "value": null + } + }, + "samsungce.powerSavingWhileAway": { + "supportedPowerSavings": { + "value": null + }, + "detectionMethod": { + "value": null + }, + "powerSaving": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "10257341", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "600201482018110B46004F3000F3A900", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "description": { + "value": "TP1X_DA-AC-CAC-01001_0000", + "timestamp": "2025-08-19T02:01:25.872Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA-AC-CAC-01001_0000", + "timestamp": "2025-08-19T02:01:25.874Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-08-19T12:19:48.013Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-08-19T02:01:26.830Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-08-19T12:09:54.052Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "high", + "timestamp": "2025-08-19T02:01:25.961Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high"], + "timestamp": "2025-07-31T13:50:35.882Z" + }, + "availableAcFanModes": { + "value": [], + "timestamp": "2025-08-19T12:19:48.005Z" + } + }, + "custom.electricHepaFilter": { + "electricHepaFilterCapacity": { + "value": null + }, + "electricHepaFilterUsageStep": { + "value": null + }, + "electricHepaFilterLastResetDate": { + "value": null + }, + "electricHepaFilterStatus": { + "value": null + }, + "electricHepaFilterUsage": { + "value": null + }, + "electricHepaFilterResetType": { + "value": null + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": "unavailable", + "timestamp": "2025-07-29T18:39:12.233Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 28, + "unit": "C", + "timestamp": "2025-08-19T12:26:10.865Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "sec.calmConnectionCare": { + "role": { + "value": ["things"], + "timestamp": "2025-08-19T02:01:25.203Z" + }, + "protocols": { + "value": null + }, + "version": { + "value": "1.0", + "timestamp": "2025-08-19T02:01:25.203Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "disabled", + "timestamp": "2025-08-19T02:01:25.776Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-08-19T02:01:25.776Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-08-19T02:01:25.776Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 18, + "maximum": 30, + "step": 0.5 + }, + "unit": "C", + "timestamp": "2025-08-19T12:09:52.939Z" + }, + "coolingSetpoint": { + "value": 20, + "unit": "C", + "timestamp": "2025-08-19T12:09:52.018Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["edgelight"], + "timestamp": "2025-07-29T18:39:13.503Z" + } + }, + "samsungce.absenceDetection": { + "supportedAbsencePeriods": { + "value": null + }, + "absencePeriod": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": [], + "timestamp": "2025-08-19T02:01:25.746Z" + }, + "alwaysOn": { + "value": "off", + "timestamp": "2025-08-19T02:01:25.746Z" + } + }, + "refresh": {}, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": null + }, + "startTime": { + "value": null + }, + "endTime": { + "value": null + } + }, + "samsungce.airConditionerAudioFeedback": { + "volumeLevel": { + "value": null + }, + "supportedVolumeLevels": { + "value": null + } + } + }, + "edgelight": { + "samsungce.airConditionerLighting": { + "supportedLightingLevels": { + "value": null + }, + "lighting": { + "value": null + } + }, + "samsungce.colorTemperature": { + "supportedColorTemperatures": { + "value": null + }, + "colorTemperature": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/tesla_powerwall.json b/tests/components/smartthings/fixtures/device_status/tesla_powerwall.json new file mode 100644 index 00000000000..c9531314d5f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/tesla_powerwall.json @@ -0,0 +1,107 @@ +{ + "components": { + "charge": { + "powerMeter": { + "power": { + "value": 0, + "unit": "W", + "timestamp": "2025-10-02T12:21:29.196Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-10-02T12:20:00Z", + "end": "2025-10-02T12:25:00Z", + "energy": 29765947, + "deltaEnergy": 0 + }, + "timestamp": "2025-10-02T12:26:24.729Z" + } + } + }, + "discharge": { + "powerMeter": { + "power": { + "value": 0, + "unit": "W", + "timestamp": "2025-10-02T11:41:20.556Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-10-02T12:20:00Z", + "end": "2025-10-02T12:25:00Z", + "energy": 27827062, + "deltaEnergy": 0 + }, + "timestamp": "2025-10-02T12:26:24.729Z" + } + } + }, + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-10-02T11:56:25.223Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-10-02T11:56:25.223Z" + } + }, + "rivertalent14263.adaptiveEnergyUsageState": { + "stormWatchEnabled": { + "value": true, + "timestamp": "2024-07-16T12:40:19.190Z" + }, + "stormWatchActive": { + "value": false, + "timestamp": "2024-07-16T12:40:19.190Z" + }, + "gridStatusSupport": { + "value": true, + "timestamp": "2024-07-16T12:40:19.190Z" + }, + "stormWatchSupport": { + "value": true, + "timestamp": "2025-09-17T18:31:31.669Z" + }, + "energyUsageState": { + "value": null + }, + "gridStatusStatus": { + "value": "on-grid", + "timestamp": "2025-09-17T18:31:31.669Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 35, + "unit": "%", + "timestamp": "2025-10-02T11:41:20.556Z" + }, + "type": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_cac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_cac_01001.json new file mode 100644 index 00000000000..fc9cafe6263 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_cac_01001.json @@ -0,0 +1,312 @@ +{ + "items": [ + { + "deviceId": "23c6d296-4656-20d8-f6eb-2ff13e041753", + "name": "Samsung System A/C", + "label": "Ar Varanda", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-CAC-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "6dcf8d88-755a-4c4a-9d41-af9e0de211e7", + "ownerId": "40de8159-c257-a9a5-8505-84fd25eb5b76", + "roomId": "845368d7-4a13-42d5-a576-c495db7910c7", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.electricHepaFilter", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.periodicSensing", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "samsungce.absenceDetection", + "version": 1 + }, + { + "id": "samsungce.airConditionerBeep", + "version": 1 + }, + { + "id": "samsungce.airConditionerAudioFeedback", + "version": 1 + }, + { + "id": "samsungce.airQualityHealthConcern", + "version": 1 + }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + }, + { + "id": "samsungce.powerSavingWhileAway", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.calmConnectionCare", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "light", + "label": "light", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.airConditionerLighting", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "edgelight", + "label": "edgelight", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.airConditionerLighting", + "version": 1 + }, + { + "id": "samsungce.colorTemperature", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-07-29T18:39:05.831Z", + "profile": { + "id": "7ad335f8-4b88-3008-be7c-ffa5571fac91" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Samsung System A/C", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-AC-CAC-01001_0000|10257341|600201482018110B46004F3000F3A900", + "platformVersion": "SYSTEM 2.0", + "platformOS": "TizenRT 4.0", + "hwVersion": "Realtek", + "firmwareVersion": "ASA-WW-TP1-24-PACCOM_14240625", + "vendorId": "DA-AC-CAC-01001", + "vendorResourceClientServerVersion": "MediaTek Release 240625", + "lastSignupTime": "2025-07-29T18:39:05.711446014Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/tesla_powerwall.json b/tests/components/smartthings/fixtures/devices/tesla_powerwall.json new file mode 100644 index 00000000000..20e11a35355 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/tesla_powerwall.json @@ -0,0 +1,103 @@ +{ + "items": [ + { + "deviceId": "d2595c45-df6e-41ac-a7af-8e275071c19b", + "name": "UDHN-TESLA-ENERGY-BATTERY", + "label": "Powerwall", + "manufacturerName": "0AHI", + "presentationId": "STES-1-PV-TESLA-ENERGY-BATTERY", + "locationId": "d22d6401-6070-4928-8e7b-b724e2dbf425", + "ownerId": "35445a41-3ae2-4bc0-6f51-31705de6b96f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "rivertalent14263.adaptiveEnergyUsageState", + "version": 1 + }, + { + "id": "battery", + "version": 1 + } + ], + "categories": [ + { + "name": "Battery", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "discharge", + "label": "discharge", + "capabilities": [ + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "charge", + "label": "charge", + "capabilities": [ + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2024-07-16T12:40:18.632Z", + "profile": { + "id": "4f9998dc-e672-4baf-8521-5e9b853fc978" + }, + "app": { + "installedAppId": "e798c0a6-3e3b-4299-8463-438fc3f1e6b3", + "externalId": "TESLABATTERY_1689188152863574", + "profile": { + "id": "4f9998dc-e672-4baf-8521-5e9b853fc978" + } + }, + "type": "ENDPOINT_APP", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 6280bcf6770..293aa961ca7 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main', 'unit_of_measurement': None, }) @@ -130,6 +130,125 @@ 'state': 'heat', }) # --- +# name: test_all_entities[da_ac_cac_01001][climate.ar_varanda-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'none', + 'wind_free', + 'long_wind', + 'boost', + 'quiet', + 'sleep', + ]), + 'swing_modes': list([ + 'vertical', + 'off', + 'horizontal', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.ar_varanda', + '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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'air_conditioner', + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_cac_01001][climate.ar_varanda-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 28, + 'drlc_status_duration': 0, + 'drlc_status_level': 0, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'high', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Ar Varanda', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'wind_free', + 'long_wind', + 'boost', + 'quiet', + 'sleep', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'vertical', + 'off', + 'horizontal', + 'both', + ]), + 'temperature': 20, + }), + 'context': , + 'entity_id': 'climate.ar_varanda', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ac_ehs_01001][climate.heat_pump_indoor1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -222,7 +341,8 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'windFree', + 'none', + 'wind_free', ]), 'swing_modes': None, }), @@ -250,7 +370,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main', 'unit_of_measurement': None, }) @@ -282,9 +402,10 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'windFree', + 'preset_mode': 'wind_free', 'preset_modes': list([ - 'windFree', + 'none', + 'wind_free', ]), 'supported_features': , 'swing_mode': 'off', @@ -322,7 +443,13 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'windFree', + 'none', + 'sleep', + 'quiet', + 'smart', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'swing_modes': list([ 'off', @@ -355,7 +482,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', 'unit_of_measurement': None, }) @@ -384,9 +511,15 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'none', 'preset_modes': list([ - 'windFree', + 'none', + 'sleep', + 'quiet', + 'smart', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'supported_features': , 'swing_mode': 'off', @@ -430,7 +563,13 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'windFree', + 'none', + 'sleep', + 'quiet', + 'smart', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'swing_modes': list([ 'off', @@ -463,7 +602,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main', 'unit_of_measurement': None, }) @@ -495,9 +634,15 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'none', 'preset_modes': list([ - 'windFree', + 'none', + 'sleep', + 'quiet', + 'smart', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'supported_features': , 'swing_mode': 'off', @@ -564,7 +709,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main', 'unit_of_measurement': None, }) diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index d732578212a..42eaf548b36 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -343,6 +343,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_cac_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '23c6d296-4656-20d8-f6eb-2ff13e041753', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-AC-CAC-01001_0000', + 'model_id': None, + 'name': 'Ar Varanda', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': 'ASA-WW-TP1-24-PACCOM_14240625', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ac_ehs_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1862,6 +1893,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[tesla_powerwall] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd2595c45-df6e-41ac-a7af-8e275071c19b', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Powerwall', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[tplink_p110] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 7109b46cebb..c573ccbbc27 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1347,6 +1347,446 @@ 'state': '23.0', }) # --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_energy-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.ar_varanda_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ar Varanda Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ar_varanda_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '83.16', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_energy_difference-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.ar_varanda_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ar Varanda Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ar_varanda_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_energy_saved-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.ar_varanda_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ar Varanda Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ar_varanda_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_humidity-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.ar_varanda_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Ar Varanda Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ar_varanda_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_power-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.ar_varanda_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ar Varanda Power', + 'power_consumption_end': '2025-08-19T12:18:52Z', + 'power_consumption_start': '2025-08-19T02:01:25Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ar_varanda_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_power_energy-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.ar_varanda_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ar Varanda Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ar_varanda_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_temperature-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.ar_varanda_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Ar Varanda Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ar_varanda_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_volume-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': None, + 'entity_id': 'sensor.ar_varanda_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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main_audioVolume_volume_volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_cac_01001][sensor.ar_varanda_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ar Varanda Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ar_varanda_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -13529,6 +13969,56 @@ 'state': '20', }) # --- +# name: test_all_entities[tesla_powerwall][sensor.powerwall_battery-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.powerwall_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd2595c45-df6e-41ac-a7af-8e275071c19b_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[tesla_powerwall][sensor.powerwall_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Powerwall Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.powerwall_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 6512e88998b..1bd79b3307c 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -47,6 +47,54 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ac_rac_01001][switch.aire_dormitorio_principal_display_lighting-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aire_dormitorio_principal_display_lighting', + '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': 'Display lighting', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'display_lighting', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_samsungce.airConditionerLighting_lighting_lighting', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][switch.aire_dormitorio_principal_display_lighting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Display lighting', + }), + 'context': , + 'entity_id': 'switch.aire_dormitorio_principal_display_lighting', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -792,7 +840,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto cycle link', + 'original_name': 'Auto Cycle Link', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -805,7 +853,7 @@ # name: test_all_entities[da_wm_sc_000001][switch.airdresser_auto_cycle_link-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AirDresser Auto cycle link', + 'friendly_name': 'AirDresser Auto Cycle Link', }), 'context': , 'entity_id': 'switch.airdresser_auto_cycle_link', diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 6f2325cad78..d27bd042b11 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -23,6 +23,9 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, + PRESET_NONE, + PRESET_SLEEP, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, @@ -441,27 +444,40 @@ async def test_ac_set_swing_mode( ) -@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000003"]) +@pytest.mark.parametrize( + ("mode", "expected_mode"), + [ + (PRESET_NONE, "off"), + (PRESET_SLEEP, "sleep"), + ("quiet", "quiet"), + (PRESET_BOOST, "speed"), + ("wind_free", "windFree"), + ("wind_free_sleep", "windFreeSleep"), + ], +) async def test_ac_set_preset_mode( hass: HomeAssistant, devices: AsyncMock, + mode: str, + expected_mode: str, mock_config_entry: MockConfigEntry, ) -> None: - """Test climate set preset mode.""" + """Test setting and retrieving AC preset modes.""" await setup_integration(hass, mock_config_entry) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: "windFree"}, + {ATTR_ENTITY_ID: "climate.office_airfree", ATTR_PRESET_MODE: mode}, blocking=True, ) - devices.execute_device_command.assert_called_once_with( - "96a5ef74-5832-a84b-f1f7-ca799957065d", + devices.execute_device_command.assert_called_with( + "c76d6f38-1b7f-13dd-37b5-db18d5272783", Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, MAIN, - argument="windFree", + argument=expected_mode, ) diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 0eb8fda09c5..2cc4ae851fc 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -174,7 +174,7 @@ def test_sending_insecure_files_fails( patch("email.utils.make_msgid", return_value=sample_email), pytest.raises(ServiceValidationError) as exc, ): - result, _ = message.send_message(message_data, data=data) + _result, _ = message.send_message(message_data, data=data) assert exc.value.translation_key == "remote_path_not_allowed" assert exc.value.translation_domain == DOMAIN assert ( diff --git a/tests/components/snoo/snapshots/test_button.ambr b/tests/components/snoo/snapshots/test_button.ambr new file mode 100644 index 00000000000..05920b09105 --- /dev/null +++ b/tests/components/snoo/snapshots/test_button.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_entities[button.test_snoo_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_snoo_start', + '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': 'Start', + 'platform': 'snoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_snoo', + 'unique_id': 'random_num_start_snoo', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[button.test_snoo_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Snoo Start', + }), + 'context': , + 'entity_id': 'button.test_snoo_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/snoo/test_button.py b/tests/components/snoo/test_button.py new file mode 100644 index 00000000000..84705f9e6fd --- /dev/null +++ b/tests/components/snoo/test_button.py @@ -0,0 +1,41 @@ +"""Test Snoo Buttons.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + bypass_api: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test buttons.""" + with patch("homeassistant.components.snoo.PLATFORMS", [Platform.BUTTON]): + entry = await async_init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_button_starts_snoo(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test start_snoo button works correctly.""" + await async_init_integration(hass) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_snoo_start"}, + blocking=True, + ) + + assert bypass_api.start_snoo.assert_called_once diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 6831e4139c2..aff3bf671bc 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -664,6 +664,9 @@ def music_library_fixture( music_library.browse_by_idstring = Mock(side_effect=mock_browse_by_idstring) music_library.get_music_library_information = mock_get_music_library_information music_library.browse = Mock(return_value=music_library_browse_categories) + music_library.build_album_art_full_uri = Mock( + return_value="build_album_art_full_uri.jpg" + ) return music_library @@ -740,6 +743,22 @@ def current_track_info_empty_fixture(): } +@pytest.fixture(name="current_track_info") +def current_track_info_fixture(): + """Create current_track_info fixture.""" + return { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "album_art": "http://example.com/albumart.jpg", + "position": "00:00:42", + "playlist_position": "5", + "duration": "00:02:36", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + } + + @pytest.fixture(name="battery_info") def battery_info_fixture(): """Create battery_info fixture.""" @@ -835,6 +854,48 @@ def tv_event_fixture(soco): return SonosMockEvent(soco, soco.avTransport, variables) +@pytest.fixture(name="media_event") +def media_event_fixture(soco): + """Create media event fixture.""" + variables = { + "transport_state": "PLAYING", + "current_play_mode": "NORMAL", + "current_crossfade_mode": "0", + "number_of_tracks": "1", + "current_track": "1", + "current_section": "0", + "current_track_uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "current_track_duration": "360", + "current_track_meta_data": DidlMusicTrack( + album="Abbey Road", + title="Something", + parent_id="-1", + item_id="-1", + restricted=True, + resources=[], + desc=None, + album_art_uri="http://example.com/albumart.jpg", + ), + "next_track_uri": "", + "next_track_meta_data": "", + "enqueued_transport_uri": "", + "enqueued_transport_uri_meta_data": "", + "playback_storage_medium": "NETWORK", + "av_transport_uri": f"x-sonos-htastream:{soco.uid}:spdif", + "av_transport_uri_meta_data": { + "title": soco.uid, + "parent_id": "0", + "item_id": "spdif-input", + "restricted": False, + "resources": [], + "desc": None, + }, + "current_transport_actions": "Set, Play", + "current_valid_play_modes": "", + } + return SonosMockEvent(soco, soco.avTransport, variables) + + @pytest.fixture(name="zgs_discovery", scope="package") def zgs_discovery_fixture(): """Load ZoneGroupState discovery payload and return it.""" diff --git a/tests/components/sonos/snapshots/test_media_player.ambr b/tests/components/sonos/snapshots/test_media_player.ambr index 66b322ea776..f47ba2f05da 100644 --- a/tests/components/sonos/snapshots/test_media_player.ambr +++ b/tests/components/sonos/snapshots/test_media_player.ambr @@ -82,3 +82,115 @@ ]), }) # --- +# name: test_media_info_attributes[basic_track] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': 'Abbey Road', + 'media_artist': 'The Beatles', + 'media_channel': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': , + 'media_duration': 156, + 'media_playlist': None, + 'media_position': 42, + 'media_title': 'Something', + 'queue_position': 5, + 'source': None, + }) +# --- +# name: test_media_info_attributes[basic_track_no_art] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b28413f58211151', + 'media_album_name': 'Abbey Road', + 'media_artist': 'The Beatles', + 'media_channel': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': , + 'media_duration': 156, + 'media_playlist': None, + 'media_position': 42, + 'media_title': 'Something', + 'queue_position': 5, + 'source': None, + }) +# --- +# name: test_media_info_attributes[basic_track_no_position] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': 'Abbey Road', + 'media_artist': 'The Beatles', + 'media_channel': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': , + 'media_duration': None, + 'media_playlist': None, + 'media_position': None, + 'media_title': 'Something', + 'queue_position': 5, + 'source': None, + }) +# --- +# name: test_media_info_attributes[line_in] + dict({ + 'entity_picture': None, + 'media_album_name': None, + 'media_artist': None, + 'media_channel': None, + 'media_content_id': 'x-rincon-stream:0', + 'media_content_type': , + 'media_duration': None, + 'media_playlist': None, + 'media_position': None, + 'media_title': 'Line-in', + 'queue_position': None, + 'source': 'Line-in', + }) +# --- +# name: test_media_info_attributes[playlist_container] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': 'Abbey Road', + 'media_artist': 'The Beatles', + 'media_channel': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': , + 'media_duration': None, + 'media_playlist': 'My Playlist', + 'media_position': None, + 'media_title': 'Something', + 'queue_position': 5, + 'source': None, + }) +# --- +# name: test_media_info_attributes[radio_station] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': None, + 'media_artist': None, + 'media_channel': 'World News', + 'media_content_id': 'x-sonosapi-stream:1234', + 'media_content_type': , + 'media_duration': 156, + 'media_playlist': None, + 'media_position': 42, + 'media_title': 'World News', + 'queue_position': None, + 'source': None, + }) +# --- +# name: test_media_info_attributes[radio_station_with_show] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': None, + 'media_artist': None, + 'media_channel': 'World News • Live at 6', + 'media_content_id': 'x-sonosapi-stream:1234', + 'media_content_type': , + 'media_duration': 156, + 'media_playlist': None, + 'media_position': 42, + 'media_title': 'World News • Live at 6', + 'queue_position': None, + 'source': None, + }) +# --- diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 41b18750fd4..f1ce2496837 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,23 +1,39 @@ """Tests for the Sonos Media Player platform.""" +from collections.abc import Generator +from datetime import UTC, datetime from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from freezegun import freeze_time import pytest -from soco.data_structures import SearchResult +from soco.data_structures import ( + DidlAudioBroadcast, + DidlAudioLineIn, + DidlPlaylistContainer, + SearchResult, +) from sonos_websocket.exception import SonosWebsocketError from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EXTRA, + ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MP_DOMAIN, SERVICE_CLEAR_PLAYLIST, @@ -33,19 +49,23 @@ from homeassistant.components.sonos.const import ( SOURCE_TV, ) from homeassistant.components.sonos.media_player import ( + LONG_SERVICE_TIMEOUT, + VOLUME_INCREMENT, +) +from homeassistant.components.sonos.services import ( ATTR_ALARM_ID, ATTR_ENABLED, ATTR_INCLUDE_LINKED_ZONES, + ATTR_QUEUE_POSITION, ATTR_VOLUME, - LONG_SERVICE_TIMEOUT, SERVICE_GET_QUEUE, SERVICE_RESTORE, SERVICE_SNAPSHOT, SERVICE_UPDATE_ALARM, - VOLUME_INCREMENT, ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, ATTR_TIME, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, @@ -71,6 +91,13 @@ from homeassistant.setup import async_setup_component from .conftest import MockMusicServiceItem, MockSoCo, SoCoMockFactory, SonosMockEvent +@pytest.fixture(autouse=True) +def mock_token() -> Generator[MagicMock]: + """Mock token generator.""" + with patch("secrets.token_hex", return_value="123456789") as token: + yield token + + async def test_device_registry( hass: HomeAssistant, device_registry: DeviceRegistry, async_autosetup_sonos, soco ) -> None: @@ -413,6 +440,7 @@ async def test_play_media_lib_track_add( _share_link: str = "spotify:playlist:abcdefghij0123456789XY" +_share_link_title: str = "playlist title" async def test_play_media_share_link_add( @@ -430,6 +458,7 @@ async def test_play_media_share_link_add( ATTR_MEDIA_CONTENT_TYPE: "playlist", ATTR_MEDIA_CONTENT_ID: _share_link, ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.ADD, + ATTR_MEDIA_EXTRA: {"title": _share_link_title}, }, blocking=True, ) @@ -441,6 +470,10 @@ async def test_play_media_share_link_add( soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["timeout"] == LONG_SERVICE_TIMEOUT ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["dc_title"] + == _share_link_title + ) async def test_play_media_share_link_next( @@ -472,6 +505,10 @@ async def test_play_media_share_link_next( assert ( soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["position"] == 1 ) + assert ( + "dc_title" + not in soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs + ) async def test_play_media_share_link_play( @@ -1101,11 +1138,11 @@ async def test_volume( await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: "media_player.zone_a", ATTR_MEDIA_VOLUME_LEVEL: 0.30}, + {ATTR_ENTITY_ID: "media_player.zone_a", ATTR_MEDIA_VOLUME_LEVEL: 0.57}, blocking=True, ) # SoCo uses 0..100 for its range. - assert soco.volume == 30 + assert soco.volume == 57 @pytest.mark.parametrize( @@ -1335,3 +1372,208 @@ async def test_service_update_alarm_dne( blocking=True, ) assert soco.alarmClock.UpdateAlarm.call_count == 0 + + +@pytest.mark.freeze_time("2024-01-01T12:00:00Z") +async def test_position_updates( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, + media_event: SonosMockEvent, + current_track_info: dict[str, Any], +) -> None: + """Test the media player position updates.""" + + soco.get_current_track_info.return_value = current_track_info + soco.avTransport.subscribe.return_value.callback(media_event) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_id = "media_player.zone_a" + state = hass.states.get(entity_id) + + assert state.attributes[ATTR_MEDIA_POSITION] == 42 + # updated_at should be recent + updated_at = state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] + assert updated_at == datetime.now(UTC) + + # Position only updated by 1 second; should not update attributes + new_track_info = current_track_info.copy() + new_track_info["position"] = "00:00:43" + soco.get_current_track_info.return_value = new_track_info + new_media_event = SonosMockEvent( + soco, soco.avTransport, media_event.variables.copy() + ) + new_media_event.variables["position"] = "00:00:43" + with freeze_time("2024-01-01T12:00:01Z"): + soco.avTransport.subscribe.return_value.callback(new_media_event) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_POSITION] == 42 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == updated_at + + # Position jumped by more than 1.5 seconds; should update position + new_track_info = current_track_info.copy() + new_track_info["position"] = "00:01:10" + soco.get_current_track_info.return_value = new_track_info + new_media_event = SonosMockEvent( + soco, soco.avTransport, media_event.variables.copy() + ) + new_media_event.variables["position"] = "00:01:10" + with freeze_time("2024-01-01T12:00:11Z"): + soco.avTransport.subscribe.return_value.callback(new_media_event) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_POSITION] == 70 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == datetime.now(UTC) + + +@pytest.mark.parametrize( + ("track_info", "event_variables"), + [ + ( + { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "album_art": "http://example.com/albumart.jpg", + "position": "00:00:42", + "playlist_position": "5", + "duration": "00:02:36", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + }, + {}, + ), + ( + { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "position": "00:00:42", + "playlist_position": "5", + "duration": "00:02:36", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + }, + {}, + ), + ( + { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "album_art": "http://example.com/albumart.jpg", + "playlist_position": "5", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + }, + {}, + ), + ( + { + "uri": "x-rincon-stream:0", + "metadata": "NOT_IMPLEMENTED", + }, + { + "current_track_uri": "x-rincon-stream:0", + "current_track_meta_data": DidlAudioLineIn("Line-in", "-1", "-1"), + }, + ), + ( + { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "album_art": "http://example.com/albumart.jpg", + "playlist_position": "5", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + }, + { + "enqueued_transport_uri_meta_data": DidlPlaylistContainer( + "My Playlist", "-1", "-1" + ) + }, + ), + ( + { + "album_art": "http://example.com/albumart.jpg", + "position": "00:00:42", + "duration": "00:02:36", + "uri": "x-sonosapi-stream:1234", + "metadata": "NOT_IMPLEMENTED", + }, + { + "enqueued_transport_uri_meta_data": DidlAudioBroadcast( + "World News", "-1", "-1" + ), + "current_track_uri": "x-sonosapi-stream:1234", + }, + ), + ( + { + "album_art": "http://example.com/albumart.jpg", + "position": "00:00:42", + "duration": "00:02:36", + "uri": "x-sonosapi-stream:1234", + "metadata": "NOT_IMPLEMENTED", + }, + { + "enqueued_transport_uri_meta_data": DidlAudioBroadcast( + "World News", "-1", "-1" + ), + "current_track_uri": "x-sonosapi-stream:1234", + "current_track_meta_data": DidlAudioBroadcast( + "World News", "-1", "-1", radio_show="Live at 6" + ), + }, + ), + ], + ids=[ + "basic_track", + "basic_track_no_art", + "basic_track_no_position", + "line_in", + "playlist_container", + "radio_station", + "radio_station_with_show", + ], +) +async def test_media_info_attributes( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, + media_event: SonosMockEvent, + track_info: dict[str, Any], + event_variables: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test the media player info attributes using a variety of inputs.""" + media_event.variables.update(event_variables) + soco.get_current_track_info.return_value = track_info + soco.avTransport.subscribe.return_value.callback(media_event) + + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("media_player.zone_a") + + snapshot_keys = [ + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_TITLE, + ATTR_QUEUE_POSITION, + ATTR_ENTITY_PICTURE, + ATTR_INPUT_SOURCE, + ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_CHANNEL, + ] + + # Create a filtered dict of only those attributes + filtered_attrs = {k: state.attributes.get(k) for k in snapshot_keys} + + # Use the snapshot assertion + assert filtered_attrs == snapshot diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py index ada48de21f3..0a50da9b9a7 100644 --- a/tests/components/sonos/test_select.py +++ b/tests/components/sonos/test_select.py @@ -38,9 +38,9 @@ async def platform_binary_sensor_fixture(): [ (0, "off"), (1, "low"), - (2, "medium"), - (3, "high"), - (4, "max"), + ("2", "medium"), + ("3", "high"), + ("4", "max"), ], ) async def test_select_dialog_level( @@ -49,7 +49,7 @@ async def test_select_dialog_level( soco, entity_registry: er.EntityRegistry, speaker_info: dict[str, str], - level: int, + level: int | str, result: str, ) -> None: """Test dialog level select entity.""" @@ -88,6 +88,36 @@ async def test_select_dialog_invalid_level( assert dialog_level_state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("value", "result"), + [ + ("invalid_integer", "Invalid value for dialog_level_enum invalid_integer"), + (None, "Missing value for dialog_level_enum"), + ], +) +async def test_select_dialog_value_error( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + caplog: pytest.LogCaptureFixture, + value: str | None, + result: str, +) -> None: + """Test receiving a value from Sonos that is not convertible to an integer.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = value + + with caplog.at_level(logging.WARNING): + await async_setup_sonos() + assert result in caplog.text + + assert SELECT_DIALOG_LEVEL_ENTITY not in entity_registry.entities + + @pytest.mark.parametrize( ("result", "option"), [ @@ -149,12 +179,12 @@ async def test_select_dialog_level_event( speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() soco.get_speaker_info.return_value = speaker_info - soco.dialog_level = 0 + soco.dialog_level = "0" await async_setup_sonos() event = create_rendering_control_event(soco) - event.variables[ATTR_DIALOG_LEVEL] = 3 + event.variables[ATTR_DIALOG_LEVEL] = "3" soco.renderingControl.subscribe.return_value._callback(event) await hass.async_block_till_done(wait_background_tasks=True) @@ -175,11 +205,11 @@ async def test_select_dialog_level_poll( speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() soco.get_speaker_info.return_value = speaker_info - soco.dialog_level = 0 + soco.dialog_level = "0" await async_setup_sonos() - soco.dialog_level = 4 + soco.dialog_level = "4" freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 5f91cba1d94..6afc0329e32 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -10,7 +10,12 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) -from homeassistant.components.sql.const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -30,140 +35,167 @@ from homeassistant.helpers.trigger_template_entity import ( from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", - CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, - CONF_STATE_CLASS: SensorStateClass.TOTAL, + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } ENTRY_CONFIG_WITH_VALUE_TEMPLATE = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", - CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + }, } ENTRY_CONFIG_INVALID_QUERY = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_2 = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_3 = { - CONF_NAME: "Get Value", CONF_QUERY: ";;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_OPT = { CONF_QUERY: "SELECT 5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_2_OPT = { CONF_QUERY: "SELECT5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_3_OPT = { CONF_QUERY: ";;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_READ_ONLY_CTE = { - CONF_NAME: "Get Value", CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY = { - CONF_NAME: "Get Value", CONF_QUERY: "UPDATE states SET state = 999999 WHERE state_id = 11125", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE = { - CONF_NAME: "Get Value", CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_READ_ONLY_CTE_OPT = { CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT = { CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT = { CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_MULTIPLE_QUERIES = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_MULTIPLE_QUERIES_OPT = { CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_COLUMN_NAME = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT = { CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_NO_RESULTS = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT kalle as value from no_table;", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } YAML_CONFIG = { @@ -260,22 +292,29 @@ YAML_CONFIG_ALL_TEMPLATES = { } -async def init_integration( +async def init_integration( # pylint: disable=dangerous-default-value hass: HomeAssistant, - config: dict[str, Any] | None = None, + *, + title: str = "Select value SQL query", + config: dict[str, Any] = {}, + options: dict[str, Any] | None = None, entry_id: str = "1", source: str = SOURCE_USER, ) -> MockConfigEntry: """Set up the SQL integration in Home Assistant.""" - if not config: - config = ENTRY_CONFIG + if not options: + options = ENTRY_CONFIG + if CONF_ADVANCED_OPTIONS not in options: + options[CONF_ADVANCED_OPTIONS] = {} config_entry = MockConfigEntry( + title=title, domain=DOMAIN, source=source, - data={}, - options=config, + data=config, + options=options, entry_id=entry_id, + version=2, ) config_entry.add_to_hass(hass) diff --git a/tests/components/sql/conftest.py b/tests/components/sql/conftest.py new file mode 100644 index 00000000000..9d18a7ddd79 --- /dev/null +++ b/tests/components/sql/conftest.py @@ -0,0 +1,17 @@ +"""Fixtures for the SQL integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sql.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 3f2400c0a32..863e87b5eae 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -3,14 +3,31 @@ from __future__ import annotations from pathlib import Path +from typing import Any from unittest.mock import patch +import pytest from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries -from homeassistant.components.recorder import Recorder -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.components.sql.const import DOMAIN +from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -36,8 +53,25 @@ from . import ( from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "recorder_mock") -async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: +DATA_CONFIG = {CONF_NAME: "Get Value"} +DATA_CONFIG_DB = {CONF_NAME: "Get Value", CONF_DB_URL: "sqlite://"} +OPTIONS_DATA_CONFIG = {} + + +@pytest.mark.parametrize( + ("data_config", "result_config"), + [ + (DATA_CONFIG, OPTIONS_DATA_CONFIG), + (DATA_CONFIG_DB, OPTIONS_DATA_CONFIG), + ], +) +async def test_form_simple( + hass: HomeAssistant, + data_config: dict[str, Any], + result_config: dict[str, Any], +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,32 +80,33 @@ async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - ENTRY_CONFIG, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + data_config, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == result_config + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_with_value_template( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_form_with_value_template(hass: HomeAssistant) -> None: """Test for with value template.""" result = await hass.config_entries.flow.async_init( @@ -80,208 +115,218 @@ async def test_form_with_value_template( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - ENTRY_CONFIG_WITH_VALUE_TEMPLATE, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + DATA_CONFIG, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG_WITH_VALUE_TEMPLATE, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + }, } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails_db_url(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_flow_fails_db_url(hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == config_entries.SOURCE_USER + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER with patch( "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", side_effect=SQLAlchemyError("error_message"), ): - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - user_input=ENTRY_CONFIG, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, ) - assert result4["errors"] == {"db_url": "db_url_invalid"} + assert result["errors"] == {CONF_DB_URL: "db_url_invalid"} -async def test_flow_fails_invalid_query( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_flow_fails_invalid_query(hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == config_entries.SOURCE_USER + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_2, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_3, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_MULTIPLE_QUERIES, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "multiple_queries", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "multiple_queries", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_NO_RESULTS, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG, ) - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["title"] == "Get Value" - assert result5["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_flow_fails_invalid_column_name( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_flow_fails_invalid_column_name(hass: HomeAssistant) -> None: """Test config flow fails invalid column name.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_COLUMN_NAME, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "column": "column_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_COLUMN_NAME: "column_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG, ) - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["title"] == "Get Value" - assert result5["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant) -> None: """Test options config flow.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) @@ -291,41 +336,43 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_options_flow_name_previously_removed( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_name_previously_removed(hass: HomeAssistant) -> None: """Test options config flow where the name was missing.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, title="Get Value Title", ) entry.add_to_hass(hass) @@ -338,54 +385,46 @@ async def test_options_flow_name_previously_removed( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value Title", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_fails_db_url( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_db_url(hass: HomeAssistant) -> None: """Test options flow fails incorrect db url.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) @@ -393,233 +432,221 @@ async def test_options_flow_fails_db_url( "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", side_effect=SQLAlchemyError("error_message"), ): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result2["errors"] == {"db_url": "db_url_invalid"} + assert result["errors"] == {CONF_DB_URL: "db_url_invalid"} -async def test_options_flow_fails_invalid_query( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_invalid_query(hass: HomeAssistant) -> None: """Test options flow fails incorrect query and template.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_2_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_3_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "multiple_queries", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "multiple_queries", } - result4 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_fails_invalid_column_name( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_invalid_column_name(hass: HomeAssistant) -> None: """Test options flow fails invalid column name.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "column": "column_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_COLUMN_NAME: "column_invalid", } - result4 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_db_url_empty( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_db_url_empty(hass: HomeAssistant) -> None: """Test options config flow with leaving db_url empty.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } async def test_full_flow_not_recorder_db( - recorder_mock: Recorder, hass: HomeAssistant, tmp_path: Path, ) -> None: @@ -632,30 +659,31 @@ async def test_full_flow_not_recorder_db( db_path = tmp_path / "db.db" db_path_str = f"sqlite:///{db_path}" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "db_url": db_path_str, - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DB_URL: db_path_str, + CONF_NAME: "Get Value", + }, + ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {CONF_DB_URL: db_path_str} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, } entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -665,76 +693,42 @@ async def test_full_flow_not_recorder_db( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "db_url": db_path_str, - "column": "value", - "unit_of_measurement": "MiB", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - } - - # Need to test same again to mitigate issue with db_url removal - result = await hass.config_entries.options.async_init(entry.entry_id) - result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "query": "SELECT 5 as value", - "db_url": db_path_str, - "column": "value", - "unit_of_measurement": "MB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MB", - } - - assert entry.options == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_device_state_class(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) @@ -742,56 +736,54 @@ async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - result3 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert "device_class" not in result3["data"] - assert "state_class" not in result3["data"] - assert result3["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert CONF_DEVICE_CLASS not in result["data"] + assert CONF_STATE_CLASS not in result["data"] + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index 409ebca27c0..c07d5c9e639 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -4,19 +4,32 @@ from __future__ import annotations from unittest.mock import patch -import pytest -import voluptuous as vol - -from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.util import get_instance -from homeassistant.components.sql import validate_sql_select -from homeassistant.components.sql.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.recorder import CONF_DB_URL, Recorder +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import YAML_CONFIG_INVALID, YAML_CONFIG_NO_DB, init_integration +from tests.common import MockConfigEntry + async def test_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test setup entry.""" @@ -54,71 +67,71 @@ async def test_setup_invalid_config( await hass.async_block_till_done() -async def test_invalid_query(hass: HomeAssistant) -> None: - """Test invalid query.""" - with pytest.raises(vol.Invalid): - validate_sql_select("DROP TABLE *") - - with pytest.raises(vol.Invalid): - validate_sql_select("SELECT5 as value") - - with pytest.raises(vol.Invalid): - validate_sql_select(";;") - - -async def test_query_no_read_only(hass: HomeAssistant) -> None: - """Test query no read only.""" - with pytest.raises(vol.Invalid): - validate_sql_select("UPDATE states SET state = 999999 WHERE state_id = 11125") - - -async def test_query_no_read_only_cte(hass: HomeAssistant) -> None: - """Test query no read only CTE.""" - with pytest.raises(vol.Invalid): - validate_sql_select( - "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;" - ) - - -async def test_multiple_queries(hass: HomeAssistant) -> None: - """Test multiple queries.""" - with pytest.raises(vol.Invalid): - validate_sql_select("SELECT 5 as value; UPDATE states SET state = 10;") - - -async def test_remove_configured_db_url_if_not_needed_when_not_needed( - recorder_mock: Recorder, - hass: HomeAssistant, +async def test_migration_from_future( + recorder_mock: Recorder, hass: HomeAssistant ) -> None: - """Test configured db_url is replaced with None if matching the recorder db.""" - recorder_db_url = get_instance(hass).db_url + """Test migration from future version fails.""" + config_entry = MockConfigEntry( + title="Test future", + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={ + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, + }, + entry_id="1", + version=3, + ) - config = { - "db_url": recorder_db_url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migration_from_v1_to_v2( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test migration from version 1 to 2.""" + config_entry = MockConfigEntry( + title="Test migration", + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={ + CONF_DB_URL: "sqlite://", + CONF_NAME: "Test migration", + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + entry_id="1", + version=1, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert config_entry.data == {} + assert config_entry.options == { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, } - config_entry = await init_integration(hass, config) - - assert config_entry.options.get("db_url") is None - - -async def test_remove_configured_db_url_if_not_needed_when_needed( - recorder_mock: Recorder, - hass: HomeAssistant, -) -> None: - """Test configured db_url is not replaced if it differs from the recorder db.""" - db_url = "mssql://" - - config = { - "db_url": db_url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", - } - - config_entry = await init_integration(hass, config) - - assert config_entry.options.get("db_url") == db_url + state = hass.states.get("sensor.test_migration") + assert state.state == "5" diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 354840c518e..73879065999 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -12,14 +12,27 @@ from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy.exc import SQLAlchemyError -from homeassistant.components.recorder import Recorder -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.components.sql.const import CONF_QUERY, DOMAIN -from homeassistant.components.sql.sensor import _generate_lambda_stmt +from homeassistant.components.recorder import CONF_DB_URL, Recorder +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) +from homeassistant.components.sql.util import generate_lambda_stmt from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ICON, + CONF_NAME, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfInformation, @@ -37,7 +50,6 @@ from . import ( YAML_CONFIG_FULL_TABLE_SCAN, YAML_CONFIG_FULL_TABLE_SCAN_NO_UNIQUE_ID, YAML_CONFIG_FULL_TABLE_SCAN_WITH_MULTIPLE_COLUMNS, - YAML_CONFIG_WITH_VIEW_THAT_CONTAINS_ENTITY_ID, init_integration, ) @@ -46,14 +58,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query", options=options) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -62,14 +71,11 @@ async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None async def test_query_cte(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor with CTE.""" - config = { - "db_url": "sqlite://", - "query": "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", - "column": "state", - "name": "Select value SQL query CTE", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query CTE", options=options) state = hass.states.get("sensor.select_value_sql_query_cte") assert state.state == "10" @@ -80,31 +86,39 @@ async def test_query_value_template( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5.01 as value", - "column": "value", - "name": "count_tables", - "value_template": "{{ value | int }}", + options = { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == "5" + assert state.attributes == { + "device_class": "data_size", + "friendly_name": "count_tables", + "state_class": "measurement", + "unit_of_measurement": "MiB", + "value": 5.01, + } async def test_query_value_template_invalid( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5.01 as value", - "column": "value", - "name": "count_tables", - "value_template": "{{ value | dontwork }}", + options = { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_VALUE_TEMPLATE: "{{ value | dontwork }}", } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == "5.01" @@ -112,13 +126,11 @@ async def test_query_value_template_invalid( async def test_query_limit(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor with a query containing 'LIMIT' in lowercase.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value limit 1", - "column": "value", - "name": "Select value SQL query", + options = { + CONF_QUERY: "SELECT 5 as value limit 1", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, options=options) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -129,13 +141,11 @@ async def test_query_no_value( recorder_mock: Recorder, hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test the SQL sensor with a query that returns no value.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value where 1=2", - "column": "value", - "name": "count_tables", + options = { + CONF_QUERY: "SELECT 5 as value where 1=2", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == STATE_UNKNOWN @@ -163,13 +173,13 @@ async def test_query_on_disk_sqlite_no_result( await hass.async_add_executor_job(make_test_db) - config = { - "db_url": db_path_str, - "query": "SELECT value from users", - "column": "value", - "name": "count_users", + config = {CONF_DB_URL: db_path_str} + options = { + CONF_QUERY: "SELECT value from users", + CONF_COLUMN_NAME: "value", + CONF_NAME: "count_users", } - await init_integration(hass, config) + await init_integration(hass, title="count_users", options=options, config=config) state = hass.states.get("sensor.count_users") assert state.state == STATE_UNKNOWN @@ -203,23 +213,23 @@ async def test_invalid_url_setup( ) -> None: """Test invalid db url with redacted credentials.""" config = { - "db_url": url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } entry = MockConfigEntry( + title="count_tables", domain=DOMAIN, source=SOURCE_USER, - data={}, + data={CONF_DB_URL: url}, options=config, entry_id="1", + version=2, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.sql.sensor.sqlalchemy.create_engine", + "homeassistant.components.sql.util.sqlalchemy.create_engine", side_effect=SQLAlchemyError(url), ): await hass.config_entries.async_setup(entry.entry_id) @@ -237,11 +247,9 @@ async def test_invalid_url_on_update( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid db url with redacted credentials on retry.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } class MockSession: @@ -252,10 +260,10 @@ async def test_invalid_url_on_update( raise SQLAlchemyError("sqlite://homeassistant:hunter2@homeassistant.local") with patch( - "homeassistant.components.sql.sensor.scoped_session", + "homeassistant.components.sql.util.scoped_session", return_value=MockSession, ): - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) async_fire_time_changed( hass, dt_util.utcnow() + timedelta(minutes=1), @@ -337,18 +345,18 @@ async def test_templates_with_yaml( async def test_config_from_old_yaml( - recorder_mock: Recorder, hass: HomeAssistant + recorder_mock: Recorder, hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test the SQL sensor from old yaml config does not create any entity.""" config = { "sensor": { "platform": "sql", - "db_url": "sqlite://", + CONF_DB_URL: "sqlite://", "queries": [ { - "name": "count_tables", - "query": "SELECT 5 as value", - "column": "value", + CONF_NAME: "count_tables", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } ], } @@ -358,6 +366,9 @@ async def test_config_from_old_yaml( state = hass.states.get("sensor.count_tables") assert not state + issue = issue_registry.async_get_issue(DOMAIN, "sensor_platform_yaml_not_supported") + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING @pytest.mark.parametrize( @@ -386,15 +397,15 @@ async def test_invalid_url_setup_from_yaml( """Test invalid db url with redacted credentials from yaml setup.""" config = { "sql": { - "db_url": url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + CONF_DB_URL: url, + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_NAME: "count_tables", } } with patch( - "homeassistant.components.sql.sensor.sqlalchemy.create_engine", + "homeassistant.components.sql.util.sqlalchemy.create_engine", side_effect=SQLAlchemyError(url), ): assert await async_setup_component(hass, DOMAIN, config) @@ -417,9 +428,9 @@ async def test_attributes_from_yaml_setup( state = hass.states.get("sensor.get_value") assert state.state == "5" - assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE - assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT - assert state.attributes["unit_of_measurement"] == UnitOfInformation.MEBIBYTES + assert state.attributes[CONF_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE + assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.MEASUREMENT + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == UnitOfInformation.MEBIBYTES async def test_binary_data_from_yaml_setup( @@ -455,7 +466,7 @@ async def test_issue_when_using_old_query( issue = issue_registry.async_get_issue( DOMAIN, f"entity_id_query_does_full_table_scan_{unique_id}" ) - assert issue.translation_placeholders == {"query": config[CONF_QUERY]} + assert issue.translation_placeholders == {CONF_QUERY: config[CONF_QUERY]} @pytest.mark.parametrize( @@ -486,7 +497,7 @@ async def test_issue_when_using_old_query_without_unique_id( issue = issue_registry.async_get_issue( DOMAIN, f"entity_id_query_does_full_table_scan_{query}" ) - assert issue.translation_placeholders == {"query": query} + assert issue.translation_placeholders == {CONF_QUERY: query} async def test_no_issue_when_view_has_the_text_entity_id_in_it( @@ -498,7 +509,12 @@ async def test_no_issue_when_view_has_the_text_entity_id_in_it( "homeassistant.components.sql.sensor.scoped_session", ): await init_integration( - hass, YAML_CONFIG_WITH_VIEW_THAT_CONTAINS_ENTITY_ID["sql"] + hass, + title="Get entity_id", + options={ + CONF_QUERY: "SELECT value from view_sensor_db_unique_entity_ids;", + CONF_COLUMN_NAME: "value", + }, ) async_fire_time_changed( hass, @@ -516,20 +532,18 @@ async def test_multiple_sensors_using_same_db( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test multiple sensors using the same db.""" - config = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - config2 = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query 2", + options2 = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) - await init_integration(hass, config2, entry_id="2") + await init_integration(hass, title="Select value SQL query", options=options) + await init_integration( + hass, title="Select value SQL query 2", options=options2, entry_id="2" + ) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -547,13 +561,14 @@ async def test_engine_is_disposed_at_stop( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test we dispose of the engine at stop.""" - config = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", + config = {CONF_DB_URL: "sqlite:///"} + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration( + hass, title="Select value SQL query", config=config, options=options + ) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -572,13 +587,15 @@ async def test_attributes_from_entry_config( await init_integration( hass, - config={ - "name": "Get Value - With", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + title="Get Value - With", + options={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, entry_id="8693d4782ced4fb1ecca4743f29ab8f1", ) @@ -586,27 +603,29 @@ async def test_attributes_from_entry_config( state = hass.states.get("sensor.get_value_with") assert state.state == "5" assert state.attributes["value"] == 5 - assert state.attributes["unit_of_measurement"] == "MiB" - assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE - assert state.attributes["state_class"] == SensorStateClass.TOTAL + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == "MiB" + assert state.attributes[CONF_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE + assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.TOTAL await init_integration( hass, - config={ - "name": "Get Value - Without", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + title="Get Value - Without", + options={ + CONF_QUERY: "SELECT 6 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, entry_id="7aec7cd8045fba4778bb0621469e3cd9", ) state = hass.states.get("sensor.get_value_without") - assert state.state == "5" - assert state.attributes["value"] == 5 - assert state.attributes["unit_of_measurement"] == "MiB" - assert "device_class" not in state.attributes - assert "state_class" not in state.attributes + assert state.state == "6" + assert state.attributes["value"] == 6 + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == "MiB" + assert CONF_DEVICE_CLASS not in state.attributes + assert CONF_STATE_CLASS not in state.attributes async def test_query_recover_from_rollback( @@ -616,14 +635,12 @@ async def test_query_recover_from_rollback( caplog: pytest.LogCaptureFixture, ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_UNIQUE_ID: "very_unique_id", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query", options=options) platforms = async_get_platforms(hass, "sql") sql_entity = platforms[0].entities["sensor.select_value_sql_query"] @@ -634,7 +651,7 @@ async def test_query_recover_from_rollback( with patch.object( sql_entity, "_lambda_stmt", - _generate_lambda_stmt("Faulty syntax create operational issue"), + generate_lambda_stmt("Faulty syntax create operational issue"), ): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -671,7 +688,7 @@ async def test_availability_blocks_value_template( """Test availability blocks value_template from rendering.""" error = "Error parsing value for sensor.get_value: 'x' is undefined" config = YAML_CONFIG - config["sql"]["value_template"] = "{{ x - 0 }}" + config["sql"][CONF_VALUE_TEMPLATE] = "{{ x - 0 }}" config["sql"]["availability"] = '{{ states("sensor.input1")=="on" }}' hass.states.async_set("sensor.input1", "off") diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py index 004b511a2f0..737a5e4a41b 100644 --- a/tests/components/sql/test_util.py +++ b/tests/components/sql/test_util.py @@ -1,7 +1,10 @@ """Test the sql utils.""" +import pytest +import voluptuous as vol + from homeassistant.components.recorder import Recorder, get_instance -from homeassistant.components.sql.util import resolve_db_url +from homeassistant.components.sql.util import resolve_db_url, validate_sql_select from homeassistant.core import HomeAssistant @@ -22,3 +25,42 @@ async def test_resolve_db_url_when_configured(hass: HomeAssistant) -> None: resolved_url = resolve_db_url(hass, db_url) assert resolved_url == db_url + + +@pytest.mark.parametrize( + ("sql_query", "expected_error_message"), + [ + ( + "DROP TABLE *", + "Only SELECT queries allowed", + ), + ( + "SELECT5 as value", + "Invalid SQL query", + ), + ( + ";;", + "Invalid SQL query", + ), + ( + "UPDATE states SET state = 999999 WHERE state_id = 11125", + "Only SELECT queries allowed", + ), + ( + "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", + "Only SELECT queries allowed", + ), + ( + "SELECT 5 as value; UPDATE states SET state = 10;", + "Multiple SQL queries are not supported", + ), + ], +) +async def test_invalid_sql_queries( + hass: HomeAssistant, + sql_query: str, + expected_error_message: str, +) -> None: + """Test that various invalid or disallowed SQL queries raise the correct exception.""" + with pytest.raises(vol.Invalid, match=expected_error_message): + validate_sql_select(sql_query) diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 2dd9403d53f..516a574658d 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -368,70 +368,39 @@ async def configure_squeezebox_media_player_button_platform( await hass.async_block_till_done(wait_background_tasks=True) -async def configure_squeezebox_switch_platform( - hass: HomeAssistant, - config_entry: MockConfigEntry, - lms: MagicMock, -) -> None: - """Configure a squeezebox config entry with appropriate mocks for switch.""" - with ( - patch( - "homeassistant.components.squeezebox.PLATFORMS", - [Platform.SWITCH], - ), - patch("homeassistant.components.squeezebox.Server", return_value=lms), - ): - # Set up the switch platform. - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done(wait_background_tasks=True) - - @pytest.fixture -async def mock_alarms_player( - hass: HomeAssistant, - config_entry: MockConfigEntry, - lms: MagicMock, -) -> MagicMock: - """Mock the alarms of a configured player.""" - players = await lms.async_get_players() - players[0].alarms = [ - { - "id": TEST_ALARM_ID, - "enabled": True, - "time": "07:00", - "dow": [0, 1, 2, 3, 4, 5, 6], - "repeat": False, - "url": "CURRENT_PLAYLIST", - "volume": 50, - }, - ] - await configure_squeezebox_switch_platform(hass, config_entry, lms) - return players[0] +async def setup_squeezebox( + hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock +) -> MockConfigEntry: + """Fixture setting up a squeezebox config entry with one player.""" + with patch("homeassistant.components.squeezebox.Server", return_value=lms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry @pytest.fixture async def configured_player( - hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock + hass: HomeAssistant, + setup_squeezebox: MockConfigEntry, # depend on your setup fixture + lms: MagicMock, ) -> MagicMock: """Fixture mocking calls to pysqueezebox Player from a configured squeezebox.""" - await configure_squeezebox_media_player_platform(hass, config_entry, lms) - return (await lms.async_get_players())[0] - - -@pytest.fixture -async def configured_player_with_button( - hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock -) -> MagicMock: - """Fixture mocking calls to pysqueezebox Player from a configured squeezebox.""" - await configure_squeezebox_media_player_button_platform(hass, config_entry, lms) + # At this point, setup_squeezebox has already patched Server and set up the entry return (await lms.async_get_players())[0] @pytest.fixture async def configured_players( - hass: HomeAssistant, config_entry: MockConfigEntry, lms_factory: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms_factory: MagicMock, ) -> list[MagicMock]: - """Fixture mocking calls to two pysqueezebox Players from a configured squeezebox.""" + """Fixture mocking calls to multiple pysqueezebox Players from a configured squeezebox.""" lms = lms_factory(3, uuid=SERVER_UUIDS[0]) - await configure_squeezebox_media_player_platform(hass, config_entry, lms) + + with patch("homeassistant.components.squeezebox.Server", return_value=lms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return await lms.async_get_players() diff --git a/tests/components/squeezebox/test_button.py b/tests/components/squeezebox/test_button.py index 53c4e9ef626..b1df528c283 100644 --- a/tests/components/squeezebox/test_button.py +++ b/tests/components/squeezebox/test_button.py @@ -1,14 +1,23 @@ """Tests for the squeezebox button component.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +@pytest.fixture(autouse=True) +def squeezebox_button_platform(): + """Only set up the media_player platform for squeezebox tests.""" + with patch("homeassistant.components.squeezebox.PLATFORMS", [Platform.BUTTON]): + yield + + async def test_squeezebox_press( - hass: HomeAssistant, configured_player_with_button: MagicMock + hass: HomeAssistant, configured_player: MagicMock ) -> None: """Test press service call.""" await hass.services.async_call( @@ -18,6 +27,4 @@ async def test_squeezebox_press( blocking=True, ) - configured_player_with_button.async_query.assert_called_with( - "button", "preset_1.single" - ) + configured_player.async_query.assert_called_with("button", "preset_1.single") diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index cae3672061b..18c1fe2d7ae 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -15,6 +15,8 @@ from homeassistant.components.squeezebox.const import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -134,7 +136,8 @@ async def test_options_form(hass: HomeAssistant) -> None: async def test_user_form_timeout(hass: HomeAssistant) -> None: - """Test we handle server search timeout.""" + """Test we handle server search timeout and allow manual entry.""" + # First flow: simulate timeout with ( patch( "homeassistant.components.squeezebox.config_flow.async_discover", @@ -148,16 +151,46 @@ async def test_user_form_timeout(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "no_server_found"} - # simulate manual input of host - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: HOST2} + # Second flow: simulate successful discovery + with ( + patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_discover, + ), + patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), + patch( + "homeassistant.components.squeezebox.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "edit" - assert CONF_HOST in result2["data_schema"].schema - for key in result2["data_schema"].schema: - if key == CONF_HOST: - assert key.description == {"suggested_value": HOST2} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } async def test_user_form_duplicate(hass: HomeAssistant) -> None: @@ -314,11 +347,15 @@ async def test_form_validate_exception(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" + """Test we handle cannot connect error, then succeed after retry.""" + + # Start the flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "edit"} ) + assert result["type"] is FlowResultType.FORM + # First attempt: simulate cannot connect with patch( "pysqueezebox.Server.async_query", return_value=False, @@ -328,17 +365,47 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: { CONF_HOST: HOST, CONF_PORT: PORT, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: "", + CONF_PASSWORD: "", }, ) + # We should still be in a form, with an error assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} + # Second attempt: simulate a successful connection + with patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST # the flow uses host as title + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } + assert result["context"]["unique_id"] == UUID + async def test_discovery(hass: HomeAssistant) -> None: - """Test handling of discovered server.""" + """Test handling of discovered server, then completing the flow.""" + + # Initial discovery: server responds with a uuid with patch( "pysqueezebox.Server.async_query", return_value={"uuid": UUID}, @@ -348,24 +415,109 @@ async def test_discovery(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={CONF_HOST: HOST, CONF_PORT: PORT, "uuid": UUID}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "edit" + + # Discovery puts us into the edit step + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + # Complete the edit step with user input + with patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + + # Flow should now complete with a config entry + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } + assert result["context"]["unique_id"] == UUID async def test_discovery_no_uuid(hass: HomeAssistant) -> None: - """Test handling of discovered server with unavailable uuid.""" - with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized): + """Test discovery without uuid first fails, then succeeds when uuid is available.""" + + # Initial discovery: no uuid returned + with patch( + "pysqueezebox.Server.async_query", + new=patch_async_query_unauthorized, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "edit" + + # Flow shows the edit form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + # First attempt to complete: still no uuid → error on the form + with patch( + "pysqueezebox.Server.async_query", + new=patch_async_query_unauthorized, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Second attempt: now the server responds with a uuid + with patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + + # Flow should now complete successfully + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } + assert result["context"]["unique_id"] == UUID async def test_dhcp_discovery(hass: HomeAssistant) -> None: - """Test we can process discovery from dhcp.""" + """Test we can process discovery from dhcp and complete the flow.""" + with ( patch( "pysqueezebox.Server.async_query", @@ -380,17 +532,48 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip="1.1.1.1", + ip=HOST, macaddress="aabbccddeeff", hostname="any", ), ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "edit" + + # DHCP discovery puts us into the edit step + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + # Complete the edit step with user input + with patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + + # Flow should now complete with a config entry + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } + assert result["context"]["unique_id"] == UUID async def test_dhcp_discovery_no_server_found(hass: HomeAssistant) -> None: """Test we can handle dhcp discovery when no server is found.""" + with ( patch( "homeassistant.components.squeezebox.config_flow.async_discover", @@ -402,28 +585,68 @@ async def test_dhcp_discovery_no_server_found(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip="1.1.1.1", + ip=HOST, macaddress="aabbccddeeff", hostname="any", ), ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + # First step: user form with only host + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" -async def test_dhcp_discovery_existing_player(hass: HomeAssistant) -> None: - """Test that we properly ignore known players during dhcp discover.""" + # Provide just the host to move into edit step + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: HOST}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + # Now try to complete the edit step with full schema with patch( - "homeassistant.helpers.entity_registry.EntityRegistry.async_get_entity_id", - return_value="test_entity", + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_failed_discover, ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - macaddress="aabbccddeeff", - hostname="any", - ), + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, ) - assert result["type"] is FlowResultType.ABORT + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + assert result["errors"] == {"base": "unknown"} + + +async def test_dhcp_discovery_existing_player( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that we properly ignore known players during dhcp discover.""" + + # Register a squeezebox media_player entity with the same MAC unique_id + entity_registry.async_get_or_create( + domain="media_player", + platform=DOMAIN, + unique_id=format_mac("aabbccddeeff"), + ) + + # Now fire a DHCP discovery for the same MAC + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + macaddress="aabbccddeeff", + hostname="any", + ), + ) + + # Because the player is already known, the flow should abort + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index 5cb7e19abb5..a39a3020038 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -3,10 +3,12 @@ from http import HTTPStatus from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.squeezebox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry @@ -15,6 +17,15 @@ from .conftest import TEST_MAC from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def squeezebox_media_player_platform(): + """Only set up the media_player platform for squeezebox tests.""" + with patch( + "homeassistant.components.squeezebox.PLATFORMS", [Platform.MEDIA_PLAYER] + ): + yield + + async def test_init_api_fail( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index 093e4f186d4..ee0cfaf5015 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -428,3 +428,35 @@ async def test_play_browse_item_bad_category( }, blocking=True, ) + + +async def test_synthetic_thumbnail_item_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test synthetic ID generation and url caching for items without stable IDs.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "apps", + } + ) + response = await client.receive_json() + assert response["success"] + + children = response["result"]["children"] + assert len(children) > 0 + for child in children: + if thumbnail := child.get("thumbnail"): + assert not thumbnail.startswith("http://lms.internal") + assert thumbnail.startswith("/api/media_player_proxy/") diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 6e3e5be0459..d04e68f2518 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -65,22 +65,27 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.dt import utcnow -from .conftest import ( - FAKE_VALID_ITEM_ID, - TEST_MAC, - TEST_VOLUME_STEP, - configure_squeezebox_media_player_platform, -) +from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC, TEST_VOLUME_STEP from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.fixture(autouse=True) +def squeezebox_media_player_platform(): + """Only set up the media_player platform for squeezebox tests.""" + with patch( + "homeassistant.components.squeezebox.PLATFORMS", [Platform.MEDIA_PLAYER] + ): + yield + + async def test_entity_registry( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -98,10 +103,11 @@ async def test_squeezebox_new_player_discovery( lms: MagicMock, player_factory: MagicMock, freezer: FrozenDateTimeFactory, + setup_squeezebox: MockConfigEntry, ) -> None: """Test discovery of a new squeezebox player.""" # Initial setup with one player (from the 'lms' fixture) - await configure_squeezebox_media_player_platform(hass, config_entry, lms) + # await setup_squeezebox await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get("media_player.test_player") is not None assert hass.states.get("media_player.test_player_2") is None diff --git a/tests/components/squeezebox/test_switch.py b/tests/components/squeezebox/test_switch.py index 2e6e9bafeb0..368fa1bf84a 100644 --- a/tests/components/squeezebox/test_switch.py +++ b/tests/components/squeezebox/test_switch.py @@ -1,14 +1,20 @@ """Tests for the Squeezebox alarm switch platform.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.squeezebox.const import PLAYER_UPDATE_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import CONF_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + CONF_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -17,6 +23,40 @@ from .conftest import TEST_ALARM_ID from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.fixture(autouse=True) +def squeezebox_alarm_platform(): + """Only set up the media_player platform for squeezebox tests.""" + with patch("homeassistant.components.squeezebox.PLATFORMS", [Platform.SWITCH]): + yield + + +@pytest.fixture +async def mock_alarms_player( + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms: MagicMock, +) -> MagicMock: + """Mock the alarms of a configured player.""" + players = await lms.async_get_players() + players[0].alarms = [ + { + "id": TEST_ALARM_ID, + "enabled": True, + "time": "07:00", + "dow": [0, 1, 2, 3, 4, 5, 6], + "repeat": False, + "url": "CURRENT_PLAYLIST", + "volume": 50, + }, + ] + + with patch("homeassistant.components.squeezebox.Server", return_value=lms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return players[0] + + async def test_entity_registry( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/ssdp/conftest.py b/tests/components/ssdp/conftest.py index 61c763ce7d4..644f449fe38 100644 --- a/tests/components/ssdp/conftest.py +++ b/tests/components/ssdp/conftest.py @@ -1,7 +1,8 @@ """Configuration for SSDP tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +import socket +from unittest.mock import AsyncMock, MagicMock, patch from async_upnp_client.server import UpnpServer from async_upnp_client.ssdp_listener import SsdpListener @@ -29,7 +30,10 @@ async def disabled_upnp_server(): with ( patch("homeassistant.components.ssdp.server.UpnpServer.async_start"), patch("homeassistant.components.ssdp.server.UpnpServer.async_stop"), - patch("homeassistant.components.ssdp.server._async_find_next_available_port"), + patch( + "homeassistant.components.ssdp.server._async_find_next_available_port", + return_value=(40000, MagicMock(spec_set=socket.socket)), + ), ): yield UpnpServer diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index f15a80771cf..e754d3d4d32 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -1,9 +1,11 @@ """Tests Starlink integration init/unload.""" +from unittest.mock import patch + from homeassistant.components.starlink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from .patchers import ( HISTORY_STATS_SUCCESS_PATCHER, @@ -12,7 +14,7 @@ from .patchers import ( STATUS_DATA_SUCCESS_PATCHER, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data async def test_successful_entry(hass: HomeAssistant) -> None: @@ -60,3 +62,53 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_restore_cache_with_accumulation(hass: HomeAssistant) -> None: + """Test configuring Starlink.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, + ) + entity_id = "sensor.starlink_energy" + + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + entity_id, + "", + ), + { + "native_value": 1, + "native_unit_of_measurement": None, + }, + ), + ), + ) + + with ( + STATUS_DATA_SUCCESS_PATCHER, + LOCATION_DATA_SUCCESS_PATCHER, + SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, + ): + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.runtime_data + assert entry.runtime_data.data + + assert hass.states.get(entity_id).state == str(1 + 0.00786231368489) + + await entry.runtime_data.async_refresh() + + assert hass.states.get(entity_id).state == str(1 + 0.00786231368489) + + with patch.object(entry.runtime_data, "always_update", return_value=True): + await entry.runtime_data.async_refresh() + + assert hass.states.get(entity_id).state == str(1 + 0.01572462736977) diff --git a/tests/components/sun/test_condition.py b/tests/components/sun/test_condition.py index 52c0d885461..0375525268d 100644 --- a/tests/components/sun/test_condition.py +++ b/tests/components/sun/test_condition.py @@ -83,7 +83,10 @@ async def test_if_action_before_sunrise_no_offset( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -156,7 +159,10 @@ async def test_if_action_after_sunrise_no_offset( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -231,8 +237,10 @@ async def test_if_action_before_sunrise_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "before_offset": "+1:00:00", + "options": { + "before": SUN_EVENT_SUNRISE, + "before_offset": "+1:00:00", + }, }, "action": {"service": "test.automation"}, } @@ -356,8 +364,7 @@ async def test_if_action_before_sunset_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": "sunset", - "before_offset": "+1:00:00", + "options": {"before": "sunset", "before_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -481,8 +488,7 @@ async def test_if_action_after_sunrise_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "after_offset": "+1:00:00", + "options": {"after": SUN_EVENT_SUNRISE, "after_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -630,8 +636,7 @@ async def test_if_action_after_sunset_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": "sunset", - "after_offset": "+1:00:00", + "options": {"after": "sunset", "after_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -707,8 +712,7 @@ async def test_if_action_after_and_before_during( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "before": SUN_EVENT_SUNSET, + "options": {"after": SUN_EVENT_SUNRISE, "before": SUN_EVENT_SUNSET}, }, "action": {"service": "test.automation"}, } @@ -812,8 +816,7 @@ async def test_if_action_before_or_after_during( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "after": SUN_EVENT_SUNSET, + "options": {"before": SUN_EVENT_SUNRISE, "after": SUN_EVENT_SUNSET}, }, "action": {"service": "test.automation"}, } @@ -941,7 +944,10 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -1020,7 +1026,10 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -1099,7 +1108,10 @@ async def test_if_action_before_sunset_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNSET}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNSET}, + }, "action": {"service": "test.automation"}, } }, @@ -1178,7 +1190,10 @@ async def test_if_action_after_sunset_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNSET}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNSET}, + }, "action": {"service": "test.automation"}, } }, diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index d64ee2d7a73..9fc401270fb 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -999,3 +999,175 @@ FLOOR_LAMP_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + +RGBICWW_STRIP_LIGHT_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RGBICWW Strip Light", + manufacturer_data={ + 2409: b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb3" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RGBICWW Strip Light", + manufacturer_data={ + 2409: b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb3" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RGBICWW Strip Light"), + time=0, + connectable=True, + tx_power=-127, +) + + +RGBICWW_FLOOR_LAMP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RGBICWW Floor Lamp", + manufacturer_data={ + 2409: b'\xdc\x06u\xa6\xfb\xb2y\x9e"\x00\x11\xb8\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb4" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RGBICWW Floor Lamp", + manufacturer_data={ + 2409: b'\xdc\x06u\xa6\xfb\xb2y\x9e"\x00\x11\xb8\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb4" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RGBICWW Floor Lamp"), + time=0, + connectable=True, + tx_power=-127, +) + +PLUG_MINI_EU_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Plug Mini (EU)", + manufacturer_data={ + 2409: b"\x94\xa9\x90T\x85^?\xa1\x00\x00\x04\xe6\x00\x00\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"?\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Plug Mini (EU)", + manufacturer_data={ + 2409: b"\x94\xa9\x90T\x85^?\xa1\x00\x00\x04\xe6\x00\x00\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"?\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Plug Mini (EU)"), + time=0, + connectable=True, + tx_power=-127, +) + + +RELAY_SWITCH_2PM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Relay Switch 2PM", + manufacturer_data={ + 2409: b"\xc0N0\xdd\xb9\xf2\x8a\xc1\x00\x00\x00\x00\x00F\x00\x00" + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"=\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Relay Switch 2PM", + manufacturer_data={ + 2409: b"\xc0N0\xdd\xb9\xf2\x8a\xc1\x00\x00\x00\x00\x00F\x00\x00" + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"=\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Relay Switch 2PM"), + time=0, + connectable=True, + tx_power=-127, +) + +K11_PLUS_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K11+ Vacuum", + manufacturer_data={2409: b"\xb0\xe9\xfe\xe4\xbf\xd8\x0b\x01\x11f\x00\x16M\x15"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00M\x00\x10\xfb\xa8"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="K11+ Vacuum", + manufacturer_data={2409: b"\xb0\xe9\xfe\xe4\xbf\xd8\x0b\x01\x11f\x00\x16M\x15"}, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00M\x00\x10\xfb\xa8" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K11+ Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +RELAY_SWITCH_1_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Relay Switch 1", + manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b";\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Relay Switch 1", + manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"=\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Relay Switch 1"), + time=0, + connectable=True, + tx_power=-127, +) + + +GARAGE_DOOR_OPENER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Garage Door Opener", + manufacturer_data={2409: b"$X|\x05BN\x0f\x00\x00\x03\x00\x00\x00\x00\x00\x00"}, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b">\x00\x00\x00", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Garage Door Opener", + manufacturer_data={2409: b"$X|\x05BN\x0f\x00\x00\x03\x00\x00\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b">\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Garage Door Opener"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index 1038bd318f5..7ad08d5a7a7 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -1,9 +1,12 @@ """Test the switchbot config flow.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch +import pytest from switchbot import SwitchbotAccountConnectionError, SwitchbotAuthenticationError +from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.switchbot.const import ( CONF_ENCRYPTION_KEY, CONF_KEY_ID, @@ -41,6 +44,30 @@ from tests.common import MockConfigEntry DOMAIN = "switchbot" +@pytest.fixture +def mock_scanners_all_active() -> Generator[None]: + """Mock all scanners as active mode.""" + mock_scanner = Mock() + mock_scanner.current_mode = BluetoothScanningMode.ACTIVE + with patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner], + ): + yield + + +@pytest.fixture +def mock_scanners_all_passive() -> Generator[None]: + """Mock all scanners as passive mode.""" + mock_scanner = Mock() + mock_scanner.current_mode = BluetoothScanningMode.PASSIVE + with patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner], + ): + yield + + async def test_bluetooth_discovery(hass: HomeAssistant) -> None: """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( @@ -248,15 +275,23 @@ async def test_async_step_bluetooth_not_connectable(hass: HomeAssistant) -> None assert result["reason"] == "not_supported" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -279,6 +314,7 @@ async def test_user_setup_wohand(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" entry = MockConfigEntry( @@ -292,29 +328,46 @@ async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None unique_id="aabbccddeeff", ) entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None: """Test setting up a switchbot replaces an ignored entry.""" entry = MockConfigEntry( domain=DOMAIN, data={}, unique_id="aabbccddeeff", source=SOURCE_IGNORE ) entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -336,15 +389,23 @@ async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOCURTAIN_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -367,9 +428,16 @@ async def test_user_setup_wocurtain(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: """Test the user initiated form with valid address.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -379,11 +447,12 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: WOHAND_SERVICE_INFO_NOT_CONNECTABLE, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} with patch_async_setup_entry() as mock_setup_entry: @@ -403,9 +472,16 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> None: """Test the user initiated form and valid address and a bot with a password.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -414,11 +490,12 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> WOHAND_SERVICE_INFO_NOT_CONNECTABLE, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( @@ -447,15 +524,23 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None: """Test the user initiated form for a bot with a password.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_ENCRYPTED_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "password" @@ -479,15 +564,23 @@ async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -545,15 +638,23 @@ async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -618,17 +719,25 @@ async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_auth_switchbot_api_down( hass: HomeAssistant, ) -> None: """Test the user initiated form for a lock when the switchbot api is down.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -658,9 +767,16 @@ async def test_user_setup_woencrypted_auth_switchbot_api_down( assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -668,11 +784,12 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: WOHAND_SERVICE_ALT_ADDRESS_INFO, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( @@ -719,14 +836,22 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wosensor(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOSENSORTH_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -749,19 +874,236 @@ async def test_user_setup_wosensor(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login(hass: HomeAssistant) -> None: + """Test the cloud login flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test successful cloud login + with ( + patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + return_value=None, + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + + # Should proceed to device selection with single device, so go to confirm + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm device setup + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_auth_failed(hass: HomeAssistant) -> None: + """Test the cloud login flow with authentication failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test authentication failure + with patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + side_effect=SwitchbotAuthenticationError("Invalid credentials"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "wrongpass", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + assert result["errors"] == {"base": "auth_failed"} + assert "Invalid credentials" in result["description_placeholders"]["error_detail"] + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_api_error(hass: HomeAssistant) -> None: + """Test the cloud login flow with API error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test API connection error + with patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + side_effect=SwitchbotAccountConnectionError("API is down"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_error" + assert result["description_placeholders"] == {"error_detail": "API is down"} + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_then_encrypted_device(hass: HomeAssistant) -> None: + """Test cloud login followed by encrypted device setup using saved credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + with ( + patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + return_value=None, + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOLOCK_SERVICE_INFO], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + + # Should go to encrypted device choice menu + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + # Choose encrypted auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + None, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + + with ( + patch_async_setup_entry() as mock_setup_entry, + patch( + "switchbot.SwitchbotLock.async_retrieve_encryption_key", + return_value={ + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + ), + patch("switchbot.SwitchbotLock.verify_encryption_key", return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Lock EEFF" + assert result["data"] == { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + CONF_SENSOR_TYPE: "lock", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_no_devices(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_async_step_user_takes_precedence_over_discovery( hass: HomeAssistant, ) -> None: @@ -774,13 +1116,20 @@ async def test_async_step_user_takes_precedence_over_discovery( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOCURTAIN_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM @@ -928,15 +1277,23 @@ async def test_options_flow_lock_pro(hass: HomeAssistant) -> None: assert entry.options[CONF_LOCK_NIGHTLATCH] is True +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None: """Test the user initiated form for a relay switch 1pm.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -976,15 +1333,23 @@ async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None: """Test the user initiated form for a relay switch 1pm.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -1048,17 +1413,25 @@ async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down( hass: HomeAssistant, ) -> None: """Test the user initiated form for a relay switch 1pm when the switchbot api is down.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -1086,3 +1459,128 @@ async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "api_error" assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} + + +@pytest.mark.usefixtures("mock_scanners_all_active") +async def test_user_skip_menu_when_all_scanners_active(hass: HomeAssistant) -> None: + """Test that menu is skipped when all scanners are in active mode.""" + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should skip menu and go directly to select_device -> confirm + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_show_menu_when_passive_scanner_present(hass: HomeAssistant) -> None: + """Test that menu is shown when any scanner is in passive mode.""" + mock_scanner_active = Mock() + mock_scanner_active.current_mode = BluetoothScanningMode.ACTIVE + mock_scanner_passive = Mock() + mock_scanner_passive.current_mode = BluetoothScanningMode.PASSIVE + + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner_active, mock_scanner_passive], + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should show menu since not all scanners are active + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + assert set(result["menu_options"]) == {"cloud_login", "select_device"} + + # Choose select_device from menu + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "select_device"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm the device + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_show_menu_when_no_scanners(hass: HomeAssistant) -> None: + """Test that menu is shown when no scanners are available.""" + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[], + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should show menu when no scanners are available + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + assert set(result["menu_options"]) == {"cloud_login", "select_device"} + + # Choose select_device from menu + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "select_device"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm the device + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 9430a45d106..670e855d8f8 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from . import ( + GARAGE_DOOR_OPENER_SERVICE_INFO, ROLLER_SHADE_SERVICE_INFO, WOBLINDTILT_SERVICE_INFO, WOCURTAIN3_SERVICE_INFO, @@ -648,3 +649,41 @@ async def test_exception_handling_cover_service( {**service_data, ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_OPEN_COVER, "open"), + (SERVICE_CLOSE_COVER, "close"), + ], +) +async def test_garage_door_opener_controlling( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + mock_method: str, +) -> None: + """Test Garage Door Opener controlling.""" + inject_bluetooth_service_info(hass, GARAGE_DOOR_OPENER_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="garage_door_opener") + entry.add_to_hass(hass) + entity_id = "cover.test_name" + + mocked_instance = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotGarageDoorOpener", + update=AsyncMock(), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mocked_instance.assert_awaited_once() diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py index 718d7aecf96..706597c6052 100644 --- a/tests/components/switchbot/test_light.py +++ b/tests/components/switchbot/test_light.py @@ -25,6 +25,8 @@ from . import ( BULB_SERVICE_INFO, CEILING_LIGHT_SERVICE_INFO, FLOOR_LAMP_SERVICE_INFO, + RGBICWW_FLOOR_LAMP_SERVICE_INFO, + RGBICWW_STRIP_LIGHT_SERVICE_INFO, STRIP_LIGHT_3_SERVICE_INFO, WOSTRIP_SERVICE_INFO, ) @@ -343,10 +345,16 @@ async def test_strip_light_services_exception( @pytest.mark.parametrize( - ("sensor_type", "service_info"), + ("sensor_type", "service_info", "dev_cls"), [ - ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO), - ("floor_lamp", FLOOR_LAMP_SERVICE_INFO), + ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO, "SwitchbotStripLight3"), + ("floor_lamp", FLOOR_LAMP_SERVICE_INFO, "SwitchbotStripLight3"), + ( + "rgbicww_strip_light", + RGBICWW_STRIP_LIGHT_SERVICE_INFO, + "SwitchbotRgbicLight", + ), + ("rgbicww_floor_lamp", RGBICWW_FLOOR_LAMP_SERVICE_INFO, "SwitchbotRgbicLight"), ], ) @pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS) @@ -355,6 +363,7 @@ async def test_floor_lamp_services( mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], sensor_type: str, service_info: BluetoothServiceInfoBleak, + dev_cls: str, service: str, service_data: dict, mock_method: str, @@ -370,7 +379,7 @@ async def test_floor_lamp_services( mocked_instance = AsyncMock(return_value=True) with patch.multiple( - "homeassistant.components.switchbot.light.switchbot.SwitchbotStripLight3", + f"homeassistant.components.switchbot.light.switchbot.{dev_cls}", **{mock_method: mocked_instance}, update=AsyncMock(return_value=None), ): diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 645eb5d1ab3..0e463240766 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -28,6 +28,8 @@ from . import ( HUB3_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, + PLUG_MINI_EU_SERVICE_INFO, + RELAY_SWITCH_2PM_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, WOHUB2_SERVICE_INFO, @@ -542,3 +544,187 @@ async def test_evaporative_humidifier_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_plug_mini_eu_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the plug mini eu sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, PLUG_MINI_EU_SERVICE_INFO) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch.get_basic_info", + new=AsyncMock( + return_value={ + "power": 500, + "current": 0.5, + "voltage": 230, + "energy": 0.4, + } + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "plug_mini_eu", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeaa", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 5 + + power_sensor = hass.states.get("sensor.test_name_power") + power_sensor_attrs = power_sensor.attributes + assert power_sensor.state == "500" + assert power_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Power" + assert power_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W" + assert power_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + voltage_sensor = hass.states.get("sensor.test_name_voltage") + voltage_sensor_attrs = voltage_sensor.attributes + assert voltage_sensor.state == "230" + assert voltage_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Voltage" + assert voltage_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "V" + assert voltage_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + current_sensor = hass.states.get("sensor.test_name_current") + current_sensor_attrs = current_sensor.attributes + assert current_sensor.state == "0.5" + assert current_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Current" + assert current_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "A" + assert current_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + energy_sensor = hass.states.get("sensor.test_name_energy") + energy_sensor_attrs = energy_sensor.attributes + assert energy_sensor.state == "0.4" + assert energy_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Energy" + assert energy_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kWh" + assert energy_sensor_attrs[ATTR_STATE_CLASS] == "total_increasing" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + assert rssi_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_relay_switch_2pm_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the relay switch 2PM sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, RELAY_SWITCH_2PM_SERVICE_INFO) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch2PM.get_basic_info", + new=AsyncMock( + return_value={ + 1: { + "power": 4.9, + "current": 0.1, + "voltage": 25, + "energy": 0.2, + }, + 2: { + "power": 7.9, + "current": 0.6, + "voltage": 25, + "energy": 2.5, + }, + } + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "relay_switch_2pm", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeaa", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 9 + + power_sensor_1 = hass.states.get("sensor.test_name_channel_1_power") + power_sensor_attrs = power_sensor_1.attributes + assert power_sensor_1.state == "4.9" + assert power_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 1 Power" + assert power_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W" + assert power_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + voltage_sensor_1 = hass.states.get("sensor.test_name_channel_1_voltage") + voltage_sensor_1_attrs = voltage_sensor_1.attributes + assert voltage_sensor_1.state == "25" + assert voltage_sensor_1_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 1 Voltage" + assert voltage_sensor_1_attrs[ATTR_UNIT_OF_MEASUREMENT] == "V" + assert voltage_sensor_1_attrs[ATTR_STATE_CLASS] == "measurement" + + current_sensor_1 = hass.states.get("sensor.test_name_channel_1_current") + current_sensor_1_attrs = current_sensor_1.attributes + assert current_sensor_1.state == "0.1" + assert current_sensor_1_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 1 Current" + assert current_sensor_1_attrs[ATTR_UNIT_OF_MEASUREMENT] == "A" + assert current_sensor_1_attrs[ATTR_STATE_CLASS] == "measurement" + + energy_sensor_1 = hass.states.get("sensor.test_name_channel_1_energy") + energy_sensor_1_attrs = energy_sensor_1.attributes + assert energy_sensor_1.state == "0.2" + assert energy_sensor_1_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 1 Energy" + assert energy_sensor_1_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kWh" + assert energy_sensor_1_attrs[ATTR_STATE_CLASS] == "total_increasing" + + power_sensor_2 = hass.states.get("sensor.test_name_channel_2_power") + power_sensor_2_attrs = power_sensor_2.attributes + assert power_sensor_2.state == "7.9" + assert power_sensor_2_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 2 Power" + assert power_sensor_2_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W" + assert power_sensor_2_attrs[ATTR_STATE_CLASS] == "measurement" + + voltage_sensor_2 = hass.states.get("sensor.test_name_channel_2_voltage") + voltage_sensor_2_attrs = voltage_sensor_2.attributes + assert voltage_sensor_2.state == "25" + assert voltage_sensor_2_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 2 Voltage" + assert voltage_sensor_2_attrs[ATTR_UNIT_OF_MEASUREMENT] == "V" + assert voltage_sensor_2_attrs[ATTR_STATE_CLASS] == "measurement" + + current_sensor_2 = hass.states.get("sensor.test_name_channel_2_current") + current_sensor_2_attrs = current_sensor_2.attributes + assert current_sensor_2.state == "0.6" + assert current_sensor_2_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 2 Current" + assert current_sensor_2_attrs[ATTR_UNIT_OF_MEASUREMENT] == "A" + assert current_sensor_2_attrs[ATTR_STATE_CLASS] == "measurement" + + energy_sensor_2 = hass.states.get("sensor.test_name_channel_2_energy") + energy_sensor_2_attrs = energy_sensor_2.attributes + assert energy_sensor_2.state == "2.5" + assert energy_sensor_2_attrs[ATTR_FRIENDLY_NAME] == "test-name Channel 2 Energy" + assert energy_sensor_2_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kWh" + assert energy_sensor_2_attrs[ATTR_STATE_CLASS] == "total_increasing" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + assert rssi_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot/test_switch.py b/tests/components/switchbot/test_switch.py index be28b2a02a8..3754dbf8170 100644 --- a/tests/components/switchbot/test_switch.py +++ b/tests/components/switchbot/test_switch.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest from switchbot.devices.device import SwitchbotOperationError +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -16,7 +17,13 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from . import WOHAND_SERVICE_INFO +from . import ( + PLUG_MINI_EU_SERVICE_INFO, + RELAY_SWITCH_1_SERVICE_INFO, + RELAY_SWITCH_2PM_SERVICE_INFO, + WOHAND_SERVICE_INFO, + WORELAY_SWITCH_1PM_SERVICE_INFO, +) from tests.common import MockConfigEntry, mock_restore_cache from tests.components.bluetooth import inject_bluetooth_service_info @@ -103,3 +110,187 @@ async def test_exception_handling_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("plug_mini_eu", PLUG_MINI_EU_SERVICE_INFO), + ("relay_switch_1", RELAY_SWITCH_1_SERVICE_INFO), + ("relay_switch_1pm", WORELAY_SWITCH_1PM_SERVICE_INFO), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "turn_on"), + (SERVICE_TURN_OFF, "turn_off"), + ], +) +async def test_relay_switch_control( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + service: str, + mock_method: str, +) -> None: + """Test Relay Switch control.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch", + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "switch.test_name" + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("service", "mock_method"), + [(SERVICE_TURN_ON, "turn_on"), (SERVICE_TURN_OFF, "turn_off")], +) +async def test_relay_switch_2pm_control( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + mock_method: str, +) -> None: + """Test Relay Switch 2PM control.""" + inject_bluetooth_service_info(hass, RELAY_SWITCH_2PM_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="relay_switch_2pm") + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch2PM", + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id_1 = "switch.test_name_channel_1" + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id_1}, + blocking=True, + ) + + mocked_instance.assert_called_with(1) + + entity_id_2 = "switch.test_name_channel_2" + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id_2}, + blocking=True, + ) + + mocked_instance.assert_called_with(2) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info", "entity_id", "mock_class"), + [ + ( + "relay_switch_1", + RELAY_SWITCH_1_SERVICE_INFO, + "switch.test_name", + "SwitchbotRelaySwitch", + ), + ( + "relay_switch_1pm", + WORELAY_SWITCH_1PM_SERVICE_INFO, + "switch.test_name", + "SwitchbotRelaySwitch", + ), + ( + "plug_mini_eu", + PLUG_MINI_EU_SERVICE_INFO, + "switch.test_name", + "SwitchbotRelaySwitch", + ), + ( + "relay_switch_2pm", + RELAY_SWITCH_2PM_SERVICE_INFO, + "switch.test_name_channel_1", + "SwitchbotRelaySwitch2PM", + ), + ( + "relay_switch_2pm", + RELAY_SWITCH_2PM_SERVICE_INFO, + "switch.test_name_channel_2", + "SwitchbotRelaySwitch2PM", + ), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "turn_on"), + (SERVICE_TURN_OFF, "turn_off"), + ], +) +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +async def test_relay_switch_control_with_exception( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + entity_id: str, + mock_class: str, + service: str, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test Relay Switch control with exception.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + with patch.multiple( + f"homeassistant.components.switchbot.switch.switchbot.{mock_class}", + update=AsyncMock(return_value=None), + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_vacuum.py b/tests/components/switchbot/test_vacuum.py index 7822bda15db..5cc579db99c 100644 --- a/tests/components/switchbot/test_vacuum.py +++ b/tests/components/switchbot/test_vacuum.py @@ -18,6 +18,7 @@ from . import ( K10_POR_COMBO_VACUUM_SERVICE_INFO, K10_PRO_VACUUM_SERVICE_INFO, K10_VACUUM_SERVICE_INFO, + K11_PLUS_VACUUM_SERVICE_INFO, K20_VACUUM_SERVICE_INFO, S10_VACUUM_SERVICE_INFO, ) @@ -34,6 +35,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ("k10_pro_combo_vacumm", K10_POR_COMBO_VACUUM_SERVICE_INFO), ("k10_vacuum", K10_VACUUM_SERVICE_INFO), ("k10_pro_vacuum", K10_PRO_VACUUM_SERVICE_INFO), + ("k11+_vacuum", K11_PLUS_VACUUM_SERVICE_INFO), ], ) @pytest.mark.parametrize( diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index b0d1c29f4a9..2fd82faa3b8 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -40,3 +40,59 @@ CIRCULATOR_FAN_INFO = Device( deviceType="Battery Circulator Fan", hubDeviceId="test-hub-id", ) + +METER_INFO = Device( + version="V1.0", + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", +) + +CONTACT_SENSOR_INFO = Device( + version="V1.7", + deviceId="contact-sensor-id", + deviceName="contact-sensor-name", + deviceType="Contact Sensor", + hubDeviceId="test-hub-id", +) + +HUB3_INFO = Device( + version="V1.3-0.8-0.1", + deviceId="hub3-id", + deviceName="Hub-3-name", + deviceType="Hub 3", + hubDeviceId="test-hub-id", +) + +MOTION_SENSOR_INFO = Device( + version="V1.9", + deviceId="motion-sensor-id", + deviceName="motion-sensor-name", + deviceType="Motion Sensor", + hubDeviceId="test-hub-id", +) + +WATER_DETECTOR_INFO = Device( + version="V1.7", + deviceId="water-detector-id", + deviceName="water-detector-name", + deviceType="Water Detector", + hubDeviceId="test-hub-id", +) + +HUMIDIFIER_INFO = Device( + version="V1.0", + deviceId="humidifier-id-1", + deviceName="humidifier-1", + deviceType="Humidifier", + hubDeviceId="test-hub-id", +) + +HUMIDIFIER2_INFO = Device( + version="V1.0", + deviceId="humidifier2-id-1", + deviceName="humidifier2-1", + deviceType="Humidifier2", + hubDeviceId="test-hub-id", +) diff --git a/tests/components/switchbot_cloud/fixtures/meter_status.json b/tests/components/switchbot_cloud/fixtures/meter_status.json deleted file mode 100644 index 8b5bcd0c031..00000000000 --- a/tests/components/switchbot_cloud/fixtures/meter_status.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": "V3.3", - "temperature": 21.8, - "battery": 100, - "humidity": 32, - "deviceId": "meter-id-1", - "deviceType": "Meter", - "hubDeviceId": "test-hub-id" -} diff --git a/tests/components/switchbot_cloud/fixtures/status.json b/tests/components/switchbot_cloud/fixtures/status.json new file mode 100644 index 00000000000..16b56d386ec --- /dev/null +++ b/tests/components/switchbot_cloud/fixtures/status.json @@ -0,0 +1,67 @@ +[ + {}, + { + "version": "V3.3", + "temperature": 21.8, + "battery": 100, + "humidity": 32, + "deviceId": "meter-id-1", + "deviceType": "Meter", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.7", + "battery": 60, + "moveDetected": true, + "brightness": "bright", + "openState": "timeOutNotClose", + "deviceId": "contact-sensor-id", + "deviceType": "Contact Sensor", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.3-0.8-0.1", + "temperature": 26.5, + "lightLevel": 10, + "humidity": 55, + "moveDetected": false, + "deviceId": "hub3-id", + "deviceType": "Hub 3", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.9", + "battery": 20, + "moveDetected": false, + "deviceId": "motion-sensor-id", + "deviceType": "Motion Sensor", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.7", + "battery": 90, + "status": 1, + "deviceId": "water-detector-id", + "deviceType": "Water Detector", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.8", + "power": "on", + "auto": false, + "humidity": 50, + "temperature": 24.3, + "deviceId": "test-id-1", + "deviceType": "Humidifier", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.0", + "power": "on", + "mode": 1, + "humidity": 50, + "deviceId": "test-id-1", + "deviceType": "Humidifier2", + "hubDeviceId": "test-hub-id" + } +] diff --git a/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..0fb71d92195 --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr @@ -0,0 +1,442 @@ +# serializer version: 1 +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_light-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': None, + 'entity_id': 'binary_sensor.contact_sensor_name_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'light', + 'friendly_name': 'contact-sensor-name Light', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_motion-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': None, + 'entity_id': 'binary_sensor.contact_sensor_name_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_moveDetected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'contact-sensor-name Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_opening-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': None, + 'entity_id': 'binary_sensor.contact_sensor_name_opening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Opening', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_openState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'contact-sensor-name Opening', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_light-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': None, + 'entity_id': 'binary_sensor.contact_sensor_name_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'light', + 'friendly_name': 'contact-sensor-name Light', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_motion-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': None, + 'entity_id': 'binary_sensor.contact_sensor_name_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_moveDetected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'contact-sensor-name Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_opening-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': None, + 'entity_id': 'binary_sensor.contact_sensor_name_opening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Opening', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_openState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'contact-sensor-name Opening', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[device_info2-3][binary_sensor.hub_3_name_motion-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': None, + 'entity_id': 'binary_sensor.hub_3_name_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'hub3-id_moveDetected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info2-3][binary_sensor.hub_3_name_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Hub-3-name Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.hub_3_name_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[device_info3-4][binary_sensor.motion_sensor_name_motion-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': None, + 'entity_id': 'binary_sensor.motion_sensor_name_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'motion-sensor-id_moveDetected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info3-4][binary_sensor.motion_sensor_name_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'motion-sensor-name Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_sensor_name_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[device_info4-5][binary_sensor.water_detector_name_moisture-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': None, + 'entity_id': 'binary_sensor.water_detector_name_moisture', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'water-detector-id_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info4-5][binary_sensor.water_detector_name_moisture-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'water-detector-name Moisture', + }), + 'context': , + 'entity_id': 'binary_sensor.water_detector_name_moisture', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr b/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000..d369b0f3b48 --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_humidifier[device_info0-6][humidifier.humidifier_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 100, + 'min_humidity': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'humidifier', + 'unique_id': 'humidifier-id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier[device_info0-6][humidifier.humidifier_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 50, + 'device_class': 'humidifier', + 'friendly_name': 'humidifier-1', + 'humidity': 50, + 'max_humidity': 100, + 'min_humidity': 1, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.humidifier_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_humidifier[device_info1-7][humidifier.humidifier2_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'high', + 'medium', + 'low', + 'quiet', + 'target_humidity', + 'sleep', + 'auto', + 'drying_filter', + ]), + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier2_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'evaporative_humidifier', + 'unique_id': 'humidifier2-id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier[device_info1-7][humidifier.humidifier2_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_modes': list([ + 'high', + 'medium', + 'low', + 'quiet', + 'target_humidity', + 'sleep', + 'auto', + 'drying_filter', + ]), + 'current_humidity': 50, + 'device_class': 'humidifier', + 'friendly_name': 'humidifier2-1', + 'humidity': 50, + 'max_humidity': 100, + 'min_humidity': 0, + 'mode': 'high', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.humidifier2_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index 83d4fa6b5a3..90939eb50e4 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_meter[sensor.meter_1_battery-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -36,7 +36,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter[sensor.meter_1_battery-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -49,10 +49,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': 'unknown', }) # --- -# name: test_meter[sensor.meter_1_humidity-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -89,7 +89,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter[sensor.meter_1_humidity-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -102,10 +102,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '32', + 'state': 'unknown', }) # --- -# name: test_meter[sensor.meter_1_temperature-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -145,7 +145,7 @@ 'unit_of_measurement': , }) # --- -# name: test_meter[sensor.meter_1_temperature-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -158,63 +158,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '21.8', - }) -# --- -# name: test_meter_no_coordinator_data[sensor.meter_1_battery-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.meter_1_battery', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'meter-id-1_battery', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter_no_coordinator_data[sensor.meter_1_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'meter-1 Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.meter_1_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , 'state': 'unknown', }) # --- -# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -229,60 +176,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meter_1_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'meter-id-1_humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'meter-1 Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.meter_1_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-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.meter_1_temperature', + 'entity_id': 'sensor.meter_1_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -292,31 +186,199 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Temperature', + 'original_name': 'Current', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'meter-id-1_temperature', - 'unit_of_measurement': , + 'unique_id': 'meter-id-1_electricCurrent', + 'unit_of_measurement': , }) # --- -# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'meter-1 Temperature', + 'device_class': 'current', + 'friendly_name': 'meter-1 Current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.meter_1_temperature', + 'entity_id': 'sensor.meter_1_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_energy-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.meter_1_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_usedElectricity', + 'unit_of_measurement': , + }) +# --- +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'meter-1 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_power-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.meter_1_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_power', + 'unit_of_measurement': , + }) +# --- +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'meter-1 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_voltage-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.meter_1_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'meter-1 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/switchbot_cloud/test_binary_sensor.py b/tests/components/switchbot_cloud/test_binary_sensor.py index 753653af9a8..49df3224cc9 100644 --- a/tests/components/switchbot_cloud/test_binary_sensor.py +++ b/tests/components/switchbot_cloud/test_binary_sensor.py @@ -2,13 +2,24 @@ from unittest.mock import patch +import pytest from switchbot_api import Device +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.switchbot_cloud.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import configure_integration +from . import ( + CONTACT_SENSOR_INFO, + HUB3_INFO, + MOTION_SENSOR_INFO, + WATER_DETECTOR_INFO, + configure_integration, +) + +from tests.common import async_load_json_array_fixture, snapshot_platform async def test_unsupported_device_type( @@ -37,3 +48,37 @@ async def test_unsupported_device_type( # Assert no binary sensor entities were created for unsupported device type entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) assert len([e for e in entities if e.domain == "binary_sensor"]) == 0 + + +@pytest.mark.parametrize( + ("device_info", "index"), + [ + (CONTACT_SENSOR_INFO, 0), + (CONTACT_SENSOR_INFO, 2), + (HUB3_INFO, 3), + (MOTION_SENSOR_INFO, 4), + (WATER_DETECTOR_INFO, 5), + ], +) +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, + device_info: Device, + index: int, +) -> None: + """Test binary sensors.""" + + mock_list_devices.return_value = [device_info] + + json_data = await async_load_json_array_fixture(hass, "status.json", DOMAIN) + mock_get_status.return_value = json_data[index] + + with patch( + "homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.BINARY_SENSOR] + ): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/switchbot_cloud/test_climate.py b/tests/components/switchbot_cloud/test_climate.py new file mode 100644 index 00000000000..05859df39d1 --- /dev/null +++ b/tests/components/switchbot_cloud/test_climate.py @@ -0,0 +1,182 @@ +"""Test for the switchbot_cloud climate.""" + +from unittest.mock import patch + +from switchbot_api import Remote + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, State + +from . import configure_integration + +from tests.common import mock_restore_cache + + +async def test_air_conditioner_set_hvac_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test setting HVAC mode for air conditioner.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="DIY Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "climate.climate_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: "cool"}, + blocking=True, + ) + mock_send_command.assert_called_once() + assert "21,2,1,on" in str(mock_send_command.call_args) + + assert hass.states.get(entity_id).state == "cool" + + # Test turning off + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: "off"}, + blocking=True, + ) + mock_send_command.assert_called_once() + assert "21,2,1,off" in str(mock_send_command.call_args) + + assert hass.states.get(entity_id).state == "off" + + +async def test_air_conditioner_set_fan_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test setting fan mode for air conditioner.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "climate.climate_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: "high"}, + blocking=True, + ) + mock_send_command.assert_called_once() + assert "21,4,4,on" in str(mock_send_command.call_args) + + assert hass.states.get(entity_id).attributes[ATTR_FAN_MODE] == "high" + + +async def test_air_conditioner_set_temperature( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test setting temperature for air conditioner.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "climate.climate_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 25}, + blocking=True, + ) + mock_send_command.assert_called_once() + assert "25,4,1,on" in str(mock_send_command.call_args) + + assert hass.states.get(entity_id).attributes[ATTR_TEMPERATURE] == 25 + + +async def test_air_conditioner_restore_state( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test restoring state for air conditioner.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + mock_state = State( + "climate.climate_1", + "cool", + { + ATTR_FAN_MODE: "high", + ATTR_TEMPERATURE: 25, + }, + ) + + mock_restore_cache(hass, (mock_state,)) + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "climate.climate_1" + state = hass.states.get(entity_id) + assert state.state == "cool" + assert state.attributes[ATTR_FAN_MODE] == "high" + assert state.attributes[ATTR_TEMPERATURE] == 25 + + +async def test_air_conditioner_no_last_state( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test behavior when no previous state exists.""" + mock_list_devices.return_value = [ + Remote( + deviceId="ac-device-id-1", + deviceName="climate-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), + ] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "climate.climate_1" + state = hass.states.get(entity_id) + assert state.state == "fan_only" + assert state.attributes[ATTR_FAN_MODE] == "auto" + assert state.attributes[ATTR_TEMPERATURE] == 21 diff --git a/tests/components/switchbot_cloud/test_cover.py b/tests/components/switchbot_cloud/test_cover.py index 0d0daf1bd7b..e2efffe0bf4 100644 --- a/tests/components/switchbot_cloud/test_cover.py +++ b/tests/components/switchbot_cloud/test_cover.py @@ -319,7 +319,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "0" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 0 ) await configure_integration(hass) @@ -334,7 +334,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "100" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 100 ) await configure_integration(hass) @@ -349,7 +349,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "50" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 50 ) diff --git a/tests/components/switchbot_cloud/test_humidifier.py b/tests/components/switchbot_cloud/test_humidifier.py new file mode 100644 index 00000000000..7b4b3caa065 --- /dev/null +++ b/tests/components/switchbot_cloud/test_humidifier.py @@ -0,0 +1,221 @@ +"""Test for the switchbot_cloud humidifiers.""" + +from unittest.mock import patch + +import pytest +import switchbot_api +from switchbot_api import Device +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import HUMIDIFIER2_INFO, HUMIDIFIER_INFO, configure_integration + +from tests.common import async_load_json_array_fixture, snapshot_platform + + +@pytest.mark.parametrize( + ("device_info", "index"), + [ + (HUMIDIFIER_INFO, 6), + (HUMIDIFIER2_INFO, 7), + ], +) +async def test_humidifier( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, + device_info: Device, + index: int, +) -> None: + """Test humidifier sensors.""" + + mock_list_devices.return_value = [device_info] + json_data = await async_load_json_array_fixture(hass, "status.json", DOMAIN) + mock_get_status.return_value = json_data[index] + + with patch( + "homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.HUMIDIFIER] + ): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_call_args"), + [ + ( + "turn_on", + {}, + ( + "humidifier-id-1", + switchbot_api.CommonCommands.ON, + "command", + "default", + ), + ), + ( + "turn_off", + {}, + ( + "humidifier-id-1", + switchbot_api.CommonCommands.OFF, + "command", + "default", + ), + ), + ( + "set_humidity", + {"humidity": 15}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "101", + ), + ), + ( + "set_humidity", + {"humidity": 60}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "102", + ), + ), + ( + "set_humidity", + {"humidity": 80}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "103", + ), + ), + ( + "set_mode", + {"mode": "auto"}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "auto", + ), + ), + ( + "set_mode", + {"mode": "normal"}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "102", + ), + ), + ], +) +async def test_humidifier_controller( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + service_data: dict, + expected_call_args: tuple, +) -> None: + """Test controlling the humidifier with mocked delay.""" + mock_list_devices.return_value = [HUMIDIFIER_INFO] + mock_get_status.return_value = {"power": "OFF", "mode": 2} + + await configure_integration(hass) + humidifier_id = "humidifier.humidifier_1" + + with patch.object(SwitchBotAPI, "send_command") as mocked_send_command: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: humidifier_id}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once_with(*expected_call_args) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_call_args"), + [ + ( + "turn_on", + {}, + ( + "humidifier2-id-1", + switchbot_api.CommonCommands.ON, + "command", + "default", + ), + ), + ( + "turn_off", + {}, + ( + "humidifier2-id-1", + switchbot_api.CommonCommands.OFF, + "command", + "default", + ), + ), + ( + "set_humidity", + {"humidity": 50}, + ( + "humidifier2-id-1", + switchbot_api.HumidifierV2Commands.SET_MODE, + "command", + {"mode": 2, "humidity": 50}, + ), + ), + ( + "set_mode", + {"mode": "auto"}, + ( + "humidifier2-id-1", + switchbot_api.HumidifierV2Commands.SET_MODE, + "command", + {"mode": 7}, + ), + ), + ], +) +async def test_humidifier2_controller( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + service_data: dict, + expected_call_args: tuple, +) -> None: + """Test controlling the humidifier2 with mocked delay.""" + mock_list_devices.return_value = [HUMIDIFIER2_INFO] + mock_get_status.return_value = {"power": "off", "mode": 2} + + await configure_integration(hass) + humidifier_id = "humidifier.humidifier2_1" + + with patch.object(SwitchBotAPI, "send_command") as mocked_send_command: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: humidifier_id}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once_with(*expected_call_args) diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index ca41f6eb99f..dfafda4110f 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -7,7 +7,12 @@ from switchbot_api import Device from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.switchbot_cloud import SwitchBotAPI from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, +) from homeassistant.core import HomeAssistant from . import configure_integration @@ -45,3 +50,33 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: lock_id}, blocking=True ) assert hass.states.get(lock_id).state == LockState.LOCKED + + +async def test_lock_open( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test lock open.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="lock-id-1", + deviceName="lock-1", + deviceType="Smart Lock Pro", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"lockState": "locked"} + + entry = await configure_integration(hass) + + assert entry.state is ConfigEntryState.LOADED + + lock_id = "lock.lock_1" + assert hass.states.get(lock_id).state == LockState.LOCKED + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: lock_id}, blocking=True + ) + assert hass.states.get(lock_id).state == LockState.UNLOCKED diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 99b6acc7401..c132c5d8ca4 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -2,53 +2,95 @@ from unittest.mock import patch +import pytest from switchbot_api import Device from syrupy.assertion import SnapshotAssertion -from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import configure_integration +from . import ( + CONTACT_SENSOR_INFO, + HUB3_INFO, + METER_INFO, + MOTION_SENSOR_INFO, + WATER_DETECTOR_INFO, + configure_integration, +) -from tests.common import async_load_json_object_fixture, snapshot_platform +from tests.common import snapshot_platform +@pytest.mark.parametrize( + ("device_info", "index"), + [ + (METER_INFO, 0), + (METER_INFO, 1), + (CONTACT_SENSOR_INFO, 2), + (HUB3_INFO, 3), + (MOTION_SENSOR_INFO, 4), + (WATER_DETECTOR_INFO, 5), + ], +) async def test_meter( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_list_devices, mock_get_status, + device_info: Device, + index: int, ) -> None: - """Test Meter sensors.""" - - mock_list_devices.return_value = [ - Device( - version="V1.0", - deviceId="meter-id-1", - deviceName="meter-1", - deviceType="Meter", - hubDeviceId="test-hub-id", - ), - ] - mock_get_status.return_value = await async_load_json_object_fixture( - hass, "meter_status.json", DOMAIN - ) - - with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): - entry = await configure_integration(hass) - - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + """Test all sensors.""" -async def test_meter_no_coordinator_data( +async def test_plug_mini_eu( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_list_devices, mock_get_status, +) -> None: + """Test plug_mini_eu Used Electricity.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="Plug-id-1", + deviceName="Plug-1", + deviceType="Plug Mini (EU)", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + { + "usedElectricity": 3255, + "deviceId": "94A99054855E", + "deviceType": "Plug Mini (EU)", + }, + ] + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "device_model", + [ + "Meter", + "Plug Mini (EU)", + ], +) +async def test_no_coordinator_data( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, + device_model, ) -> None: """Test meter sensors are unknown without coordinator data.""" mock_list_devices.return_value = [ @@ -56,7 +98,7 @@ async def test_meter_no_coordinator_data( version="V1.0", deviceId="meter-id-1", deviceName="meter-1", - deviceType="Meter", + deviceType=device_model, hubDeviceId="test-hub-id", ), ] diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index 9bd93342bae..67d0d516713 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -13,6 +13,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -108,3 +109,83 @@ async def test_pressmode_bot_no_switch_entity( entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(SWITCH_DOMAIN) + + +async def test_switch_relay_2pm_turn_on( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test switch relay 2pm turn on.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="relay-switch-id-1", + deviceName="relay-switch-1", + deviceType="Relay Switch 2PM", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"switchStatus": 0} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "switch.relay_switch_1_channel_1" + assert hass.states.get(entity_id).state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once() + + +async def test_switch_relay_2pm_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test switch relay 2pm turn off.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="relay-switch-id-1", + deviceName="relay-switch-1", + deviceType="Relay Switch 2PM", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"switchStatus": 0} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "switch.relay_switch_1_channel_1" + assert hass.states.get(entity_id).state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once() + + +async def test_switch_relay_2pm_coordination_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test switch relay 2pm coordination is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="relay-switch-id-1", + deviceName="relay-switch-1", + deviceType="Relay Switch 2PM", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = None + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "switch.relay_switch_1_channel_1" + assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/components/switcher_kis/test_services.py b/tests/components/switcher_kis/test_services.py index b4a8168419f..ab2414b2681 100644 --- a/tests/components/switcher_kis/test_services.py +++ b/tests/components/switcher_kis/test_services.py @@ -16,7 +16,7 @@ from homeassistant.components.switcher_kis.const import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.helpers.config_validation import time_period_str from homeassistant.util import slugify @@ -137,32 +137,26 @@ async def test_plug_unsupported_services( entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" # Turn on with timer - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON_WITH_TIMER_NAME, - { - ATTR_ENTITY_ID: entity_id, - CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, - }, - blocking=True, - ) + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON_WITH_TIMER_NAME, + { + ATTR_ENTITY_ID: entity_id, + CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, + }, + blocking=True, + ) assert mock_api.call_count == 0 - assert ( - f"Service '{SERVICE_TURN_ON_WITH_TIMER_NAME}' is not supported by {device.name}" - in caplog.text - ) # Auto off - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - blocking=True, - ) + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) assert mock_api.call_count == 0 - assert ( - f"Service '{SERVICE_SET_AUTO_OFF_NAME}' is not supported by {device.name}" - in caplog.text - ) diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py index 3b069d04ebe..601f437c107 100644 --- a/tests/components/synology_dsm/common.py +++ b/tests/components/synology_dsm/common.py @@ -5,6 +5,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock from awesomeversion import AwesomeVersion +from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice +from synology_dsm.api.storage.storage import SynoStorageDisk, SynoStorageVolume from .consts import SERIAL @@ -30,3 +32,173 @@ def mock_dsm_information( temperature=temperature, uptime=uptime, ) + + +def mock_dsm_storage_get_volume(volume_id: str) -> SynoStorageVolume: + """Mock SynologyDSM storage volume information for a specific volume.""" + volumes = mock_dsm_storage_volumes() + for volume in volumes: + if volume.get("id") == volume_id: + return volume + raise ValueError(f"Volume with id {volume_id} not found in mock data.") + + +def mock_dsm_storage_volumes() -> list[SynoStorageVolume]: + """Mock SynologyDSM storage volume information.""" + volumes_data = { + "volume_1": { + "id": "volume_1", + "device_type": "btrfs", + "size": { + "free_inode": "1000000", + "total": "24000277250048", + "total_device": "24000277250048", + "total_inode": "2000000", + "used": "12000138625024", + }, + "status": "normal", + "fs_type": "btrfs", + }, + } + return [SynoStorageVolume(**volume_info) for volume_info in volumes_data.values()] + + +def mock_dsm_storage_get_disk(disk_id: str) -> SynoStorageDisk: + """Mock SynologyDSM storage disk information for a specific disk.""" + disks = mock_dsm_storage_disks() + for disk in disks: + if disk.get("id") == disk_id: + return disk + raise ValueError(f"Disk with id {disk_id} not found in mock data.") + + +def mock_dsm_storage_disks() -> list[SynoStorageDisk]: + """Mock SynologyDSM storage disk information.""" + disks_data = { + "sata1": { + "id": "sata1", + "name": "Drive 1", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata1", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + "sata2": { + "id": "sata2", + "name": "Drive 2", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata2", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + "sata3": { + "id": "sata3", + "name": "Drive 3", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata3", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + } + return [SynoStorageDisk(**disk_info) for disk_info in disks_data.values()] + + +def mock_dsm_external_usb_devices_usb0() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with no USB.""" + return {} + + +def mock_dsm_external_usb_devices_usb1() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with USB Disk 1.""" + return { + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + } + + +def mock_dsm_external_usb_devices_usb2() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with USB Disk 1 and USB Disk 2.""" + return { + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + "usb2": SynoCoreExternalUSBDevice( + { + "dev_id": "usb2", + "dev_type": "usbDisk", + "dev_title": "USB Disk 2", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb2p1", + "partition_title": "USB Disk 2 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + } diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index d66688575bc..1980b8b9e69 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -9,13 +9,8 @@ import pytest from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem from synology_dsm.exceptions import SynologyDSMException -from homeassistant.components.media_player import MediaClass -from homeassistant.components.media_source import ( - BrowseError, - BrowseMedia, - MediaSourceItem, - Unresolvable, -) +from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaClass +from homeassistant.components.media_source import MediaSourceItem, Unresolvable from homeassistant.components.synology_dsm.const import DOMAIN from homeassistant.components.synology_dsm.media_source import ( SynologyDsmMediaView, diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py index 654cade2462..f636dbb79a8 100644 --- a/tests/components/synology_dsm/test_sensor.py +++ b/tests/components/synology_dsm/test_sensor.py @@ -1,9 +1,9 @@ """Tests for Synology DSM USB.""" +from itertools import chain from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice from homeassistant.components.synology_dsm.const import DOMAIN from homeassistant.const import ( @@ -17,7 +17,14 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import mock_dsm_information +from .common import ( + mock_dsm_external_usb_devices_usb0, + mock_dsm_external_usb_devices_usb1, + mock_dsm_external_usb_devices_usb2, + mock_dsm_information, + mock_dsm_storage_get_disk, + mock_dsm_storage_get_volume, +) from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry @@ -31,70 +38,33 @@ def mock_dsm_with_usb(): dsm.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) - dsm.upgrade.update = AsyncMock(return_value=True) + dsm.upgrade = Mock( + available_version=None, + available_version_details=None, + update=AsyncMock(return_value=True), + ) dsm.network = Mock( update=AsyncMock(return_value=True), macs=MACS, hostname=HOST ) dsm.information = mock_dsm_information() + dsm.storage = Mock( + get_disk=mock_dsm_storage_get_disk, + disks_ids=["sata1", "sata2", "sata3"], + disk_temp=Mock(return_value=42), + get_volume=mock_dsm_storage_get_volume, + volume_disk_temp_avg=Mock(return_value=42), + volume_size_used=Mock(return_value=12000138625024), + volume_percentage_used=Mock(return_value=38), + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) dsm.external_usb = Mock( update=AsyncMock(return_value=True), - get_device=Mock( - return_value=SynoCoreExternalUSBDevice( - { - "dev_id": "usb1", - "dev_type": "usbDisk", - "dev_title": "USB Disk 1", - "producer": "Western Digital Technologies, Inc.", - "product": "easystore 264D", - "formatable": True, - "progress": "", - "status": "normal", - "total_size_mb": 15259648, - "partitions": [ - { - "dev_fstype": "ntfs", - "filesystem": "ntfs", - "name_id": "usb1p1", - "partition_title": "USB Disk 1 Partition 1", - "share_name": "usbshare1", - "status": "normal", - "total_size_mb": 15259646, - "used_size_mb": 5942441, - } - ], - } - ) - ), - get_devices={ - "usb1": SynoCoreExternalUSBDevice( - { - "dev_id": "usb1", - "dev_type": "usbDisk", - "dev_title": "USB Disk 1", - "producer": "Western Digital Technologies, Inc.", - "product": "easystore 264D", - "formatable": True, - "progress": "", - "status": "normal", - "total_size_mb": 15259648, - "partitions": [ - { - "dev_fstype": "ntfs", - "filesystem": "ntfs", - "name_id": "usb1p1", - "partition_title": "USB Disk 1 Partition 1", - "share_name": "usbshare1", - "status": "normal", - "total_size_mb": 15259646, - "used_size_mb": 5942441, - } - ], - } - ) - }, + get_devices=mock_dsm_external_usb_devices_usb1(), ) dsm.logout = AsyncMock(return_value=True) + dsm.mock_entry = MockConfigEntry() yield dsm @@ -142,6 +112,8 @@ async def setup_dsm_with_usb( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + mock_dsm_with_usb.mock_entry = entry + yield mock_dsm_with_usb @@ -233,6 +205,150 @@ async def test_external_usb( assert sensor.attributes["attribution"] == "Data provided by Synology" +async def test_external_usb_new_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB adding new device.""" + + expected_sensors_disk_1 = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "14901.998046875", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "5803.1650390625", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "38.9", + {}, + ), + } + expected_sensors_disk_2 = { + "sensor.nas_meontheinternet_com_usb_disk_2_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_size": ( + "14901.998046875", + { + "device_class": "data_size", + "state_class": "measurement", + "unit_of_measurement": "GiB", + "attribution": "Data provided by Synology", + }, + ), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_used_space": ( + "5803.1650390625", + { + "device_class": "data_size", + "state_class": "measurement", + "unit_of_measurement": "GiB", + "attribution": "Data provided by Synology", + }, + ), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_used": ( + "38.9", + { + "state_class": "measurement", + "unit_of_measurement": "%", + "attribution": "Data provided by Synology", + }, + ), + } + + # Initial check of existing sensors + for sensor_id, (expected_state, expected_attrs) in expected_sensors_disk_1.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + for sensor_id in expected_sensors_disk_2: + assert hass.states.get(sensor_id) is None + + # Mock the get_devices method to simulate a USB disk being added + setup_dsm_with_usb.external_usb.get_devices = mock_dsm_external_usb_devices_usb2() + # Coordinator refresh + await setup_dsm_with_usb.mock_entry.runtime_data.coordinator_central.async_request_refresh() + await hass.async_block_till_done() + + for sensor_id, (expected_state, expected_attrs) in chain( + expected_sensors_disk_1.items(), expected_sensors_disk_2.items() + ): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + +async def test_external_usb_availability( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB availability.""" + + expected_sensors_disk_1_available = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "14901.998046875", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "5803.1650390625", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "38.9", + {}, + ), + } + expected_sensors_disk_1_unavailable = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("unavailable", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "unavailable", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "unavailable", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "unavailable", + {}, + ), + } + + # Initial check of existing sensors + for sensor_id, ( + expected_state, + expected_attrs, + ) in expected_sensors_disk_1_available.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + # Mock the get_devices method to simulate no USB devices being connected + setup_dsm_with_usb.external_usb.get_devices = mock_dsm_external_usb_devices_usb0() + # Coordinator refresh + await setup_dsm_with_usb.mock_entry.runtime_data.coordinator_central.async_request_refresh() + await hass.async_block_till_done() + + for sensor_id, ( + expected_state, + expected_attrs, + ) in expected_sensors_disk_1_unavailable.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + async def test_no_external_usb( hass: HomeAssistant, setup_dsm_without_usb: MagicMock, diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index 5f0a7a5c76d..c44eed77c26 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -27,12 +27,20 @@ def mock_sys_platform() -> Generator[None]: class MockProcess(Process): """Mock a Process class.""" - def __init__(self, name: str, ex: bool = False) -> None: + def __init__( + self, + name: str, + ex: bool = False, + num_fds: int | None = None, + raise_os_error: bool = False, + ) -> None: """Initialize the process.""" super().__init__(1) self._name = name self._ex = ex self._create_time = 1708700400 + self._num_fds = num_fds + self._raise_os_error = raise_os_error def name(self): """Return a name.""" @@ -40,6 +48,25 @@ class MockProcess(Process): raise NoSuchProcess(1, self._name) return self._name + def num_fds(self): + """Return the number of file descriptors opened by this process.""" + if self._ex: + raise NoSuchProcess(1, self._name) + + if self._raise_os_error: + raise OSError("Permission denied") + + # Use explicit num_fds if provided, otherwise use defaults + if self._num_fds is not None: + return self._num_fds + + # Return different values for different processes for testing + if self._name == "python3": + return 42 + if self._name == "pip": + return 15 + return 10 + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -176,7 +203,6 @@ def mock_psutil(mock_process: list[MockProcess]) -> Generator: mock_psutil.disk_partitions.return_value = [ sdiskpart("test", "/", "ext4", ""), sdiskpart("test2", "/media/share", "ext4", ""), - sdiskpart("test3", "/incorrect", "", ""), sdiskpart("hosts", "/etc/hosts", "bind", ""), sdiskpart("proc", "/proc/run", "proc", ""), ] @@ -197,7 +223,6 @@ def mock_os() -> Generator: patch("homeassistant.components.systemmonitor.coordinator.os") as mock_os, patch("homeassistant.components.systemmonitor.util.os") as mock_os_util, ): - mock_os_util.name = "nt" mock_os.getloadavg.return_value = (1, 2, 3) mock_os_util.path.isdir = isdir yield mock_os diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index afa508cc004..d306fa65514 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -22,6 +22,10 @@ }), 'load': '(1, 2, 3)', 'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)', + 'process_fds': dict({ + 'pip': 15, + 'python3': 42, + }), 'processes': "[tests.components.systemmonitor.conftest.MockProcess(pid=1, name='python3', status='sleeping', started='2024-02-23 15:00:00'), tests.components.systemmonitor.conftest.MockProcess(pid=1, name='pip', status='sleeping', started='2024-02-23 15:00:00')]", 'swap': 'sswap(total=104857600, used=62914560, free=41943040, percent=60.0, sin=1, sout=1)', 'temperatures': dict({ @@ -79,6 +83,10 @@ 'io_counters': None, 'load': '(1, 2, 3)', 'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)', + 'process_fds': dict({ + 'pip': 15, + 'python3': 42, + }), 'processes': "[tests.components.systemmonitor.conftest.MockProcess(pid=1, name='python3', status='sleeping', started='2024-02-23 15:00:00'), tests.components.systemmonitor.conftest.MockProcess(pid=1, name='pip', status='sleeping', started='2024-02-23 15:00:00')]", 'swap': 'sswap(total=104857600, used=62914560, free=41943040, percent=60.0, sin=1, sout=1)', 'temperatures': dict({ diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index 8108e4777c8..0ef5375341d 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -114,16 +114,6 @@ # name: test_sensor[System Monitor Last boot - state] '2024-02-24T15:00:00+00:00' # --- -# name: test_sensor[System Monitor Load (15 min) - attributes] - ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (15 min)', - 'icon': 'mdi:cpu-64-bit', - 'state_class': , - }) -# --- -# name: test_sensor[System Monitor Load (15 min) - state] - '3' -# --- # name: test_sensor[System Monitor Load (1 min) - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Load (1 min)', @@ -134,6 +124,16 @@ # name: test_sensor[System Monitor Load (1 min) - state] '1' # --- +# name: test_sensor[System Monitor Load (15 min) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (15 min)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (15 min) - state] + '3' +# --- # name: test_sensor[System Monitor Load (5 min) - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Load (5 min)', @@ -264,6 +264,24 @@ # name: test_sensor[System Monitor Network throughput out eth1 - state] 'unknown' # --- +# name: test_sensor[System Monitor Open file descriptors pip - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Open file descriptors pip', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Open file descriptors pip - state] + '15' +# --- +# name: test_sensor[System Monitor Open file descriptors python3 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Open file descriptors python3', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Open file descriptors python3 - state] + '42' +# --- # name: test_sensor[System Monitor Packets in eth0 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Packets in eth0', diff --git a/tests/components/systemmonitor/test_diagnostics.py b/tests/components/systemmonitor/test_diagnostics.py index f9bde984399..fa4376fc13f 100644 --- a/tests/components/systemmonitor/test_diagnostics.py +++ b/tests/components/systemmonitor/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import Mock -from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -13,6 +13,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.freeze_time("2024-02-24 15:00:00", tz_offset=0) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -27,6 +28,7 @@ async def test_diagnostics( ) == snapshot(exclude=props("last_update", "entry_id", "created_at", "modified_at")) +@pytest.mark.freeze_time("2024-02-24 15:00:00", tz_offset=0) async def test_diagnostics_missing_items( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -34,7 +36,6 @@ async def test_diagnostics_missing_items( mock_os: Mock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, - freezer: FrozenDateTimeFactory, ) -> None: """Test diagnostics.""" mock_psutil.net_if_addrs.return_value = None diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index a5f5e7623e9..e22f8e14d3d 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -18,6 +18,8 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MockProcess + from tests.common import MockConfigEntry, async_fire_time_changed @@ -313,19 +315,6 @@ async def test_processor_temperature( assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - with patch("sys.platform", "nt"): - mock_psutil.sensors_temperatures.return_value = None - mock_psutil.sensors_temperatures.side_effect = AttributeError( - "sensors_temperatures not exist" - ) - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - temp_entity = hass.states.get("sensor.system_monitor_processor_temperature") - assert temp_entity.state == STATE_UNAVAILABLE - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - with patch("sys.platform", "darwin"): mock_psutil.sensors_temperatures.return_value = { "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] @@ -433,6 +422,107 @@ async def test_cpu_percentage_is_zero_returns_unknown( assert cpu_sensor.state == "15" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_python3_num_fds( + hass: HomeAssistant, + mock_psutil: Mock, + mock_os: Mock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test python3 open file descriptors sensor.""" + mock_config_entry = MockConfigEntry( + title="System Monitor", + domain=DOMAIN, + data={}, + options={ + "binary_sensor": {"process": ["python3", "pip"]}, + "resources": [ + "disk_use_percent_/", + "disk_use_percent_/home/notexist/", + "memory_free_", + "network_out_eth0", + "process_num_fds_python3", + ], + }, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + num_fds_sensor = hass.states.get( + "sensor.system_monitor_open_file_descriptors_python3" + ) + assert num_fds_sensor is not None + assert num_fds_sensor.state == "42" + assert num_fds_sensor.attributes == { + "state_class": "measurement", + "friendly_name": "System Monitor Open file descriptors python3", + } + + _process = MockProcess("python3", num_fds=5) + assert _process.num_fds() == 5 + mock_psutil.process_iter.return_value = [_process] + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + num_fds_sensor = hass.states.get( + "sensor.system_monitor_open_file_descriptors_python3" + ) + assert num_fds_sensor is not None + assert num_fds_sensor.state == "5" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_python3_num_fds_os_error( + hass: HomeAssistant, + mock_psutil: Mock, + mock_os: Mock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test python3 open file descriptors sensor handles OSError gracefully.""" + mock_config_entry = MockConfigEntry( + title="System Monitor", + domain=DOMAIN, + data={}, + options={ + "binary_sensor": {"process": ["python3", "pip"]}, + "resources": [ + "process_num_fds_python3", + ], + }, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + num_fds_sensor = hass.states.get( + "sensor.system_monitor_open_file_descriptors_python3" + ) + assert num_fds_sensor is not None + assert num_fds_sensor.state == "42" + + _process = MockProcess("python3", raise_os_error=True) + mock_psutil.process_iter.return_value = [_process] + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Sensor should still exist but have no data (unavailable or previous value) + num_fds_sensor = hass.states.get( + "sensor.system_monitor_open_file_descriptors_python3" + ) + assert num_fds_sensor is not None + assert num_fds_sensor.state == STATE_UNKNOWN + # Check that warning was logged + assert "OS error getting file descriptor count for process 1" in caplog.text + + async def test_remove_obsolete_entities( hass: HomeAssistant, mock_psutil: Mock, @@ -453,7 +543,7 @@ async def test_remove_obsolete_entities( mock_added_config_entry.entry_id ) ) - == 37 + == 39 ) entity_registry.async_update_entity( @@ -494,7 +584,7 @@ async def test_remove_obsolete_entities( mock_added_config_entry.entry_id ) ) - == 38 + == 40 ) assert ( diff --git a/tests/components/systemmonitor/test_util.py b/tests/components/systemmonitor/test_util.py index 582707f3574..471f2f9e2cb 100644 --- a/tests/components/systemmonitor/test_util.py +++ b/tests/components/systemmonitor/test_util.py @@ -52,7 +52,6 @@ async def test_disk_util( mock_psutil.psutil.disk_partitions.return_value = [ sdiskpart("test", "/", "ext4", ""), # Should be ok sdiskpart("test2", "/media/share", "ext4", ""), # Should be ok - sdiskpart("test3", "/incorrect", "", ""), # Should be skipped as no type sdiskpart( "proc", "/proc/run", "proc", "" ), # Should be skipped as in skipped disk types @@ -62,7 +61,6 @@ async def test_disk_util( "tmpfs", "", ), # Should be skipped as in skipped disk types - sdiskpart("test5", "E:", "cd", "cdrom"), # Should be skipped as cdrom ] mock_config_entry.add_to_hass(hass) @@ -71,13 +69,9 @@ async def test_disk_util( disk_sensor1 = hass.states.get("sensor.system_monitor_disk_free") disk_sensor2 = hass.states.get("sensor.system_monitor_disk_free_media_share") - disk_sensor3 = hass.states.get("sensor.system_monitor_disk_free_incorrect") - disk_sensor4 = hass.states.get("sensor.system_monitor_disk_free_proc_run") - disk_sensor5 = hass.states.get("sensor.system_monitor_disk_free_tmpfs") - disk_sensor6 = hass.states.get("sensor.system_monitor_disk_free_e") + disk_sensor3 = hass.states.get("sensor.system_monitor_disk_free_proc_run") + disk_sensor4 = hass.states.get("sensor.system_monitor_disk_free_tmpfs") assert disk_sensor1 is not None assert disk_sensor2 is not None assert disk_sensor3 is None assert disk_sensor4 is None - assert disk_sensor5 is None - assert disk_sensor6 is None diff --git a/tests/components/tasmota/test_camera.py b/tests/components/tasmota/test_camera.py new file mode 100644 index 00000000000..4d7e137d4cd --- /dev/null +++ b/tests/components/tasmota/test_camera.py @@ -0,0 +1,308 @@ +"""The tests for the Tasmota camera platform.""" + +from asyncio import Future +import copy +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components.camera import CameraState +from homeassistant.components.tasmota.const import DEFAULT_PREFIX +from homeassistant.const import ATTR_ASSUMED_STATE, Platform +from homeassistant.core import HomeAssistant + +from .test_common import ( + DEFAULT_CONFIG, + help_test_availability, + help_test_availability_discovery_update, + help_test_availability_poll_state, + help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, + help_test_discovery_device_remove, + help_test_discovery_removal, + help_test_discovery_update_unchanged, + help_test_entity_id_update_discovery_update, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import ClientSessionGenerator, MqttMockHAClient, MqttMockPahoClient + +SMALLEST_VALID_JPEG = ( + "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" + "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" + "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" +) +SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) + + +async def test_controlling_state_via_mqtt( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test state update via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("camera.tasmota") + assert state.state == "unavailable" + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() + state = hass.states.get("camera.tasmota") + assert state.state == CameraState.IDLE + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + await help_test_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.CAMERA, config, object_id="tasmota" + ) + + +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.CAMERA, config, object_id="tasmota" + ) + + +async def test_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + await help_test_availability( + hass, mqtt_mock, Platform.CAMERA, config, object_id="tasmota" + ) + + +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.CAMERA, config, object_id="tasmota" + ) + + +async def test_availability_discovery_update( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability discovery update.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + await help_test_availability_discovery_update( + hass, mqtt_mock, Platform.CAMERA, config, object_id="tasmota" + ) + + +async def test_availability_poll_state( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test polling after MQTT connection (re)established.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + poll_topic = "tasmota_49A3BC/cmnd/STATE" + await help_test_availability_poll_state( + hass, mqtt_client_mock, mqtt_mock, Platform.CAMERA, config, poll_topic, "" + ) + + +async def test_discovery_removal_camera( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + caplog: pytest.LogCaptureFixture, + setup_tasmota, +) -> None: + """Test removal of discovered camera.""" + config1 = copy.deepcopy(DEFAULT_CONFIG) + config1["cam"] = 1 + config2 = copy.deepcopy(DEFAULT_CONFIG) + config2["cam"] = 0 + + await help_test_discovery_removal( + hass, + mqtt_mock, + caplog, + Platform.CAMERA, + config1, + config2, + object_id="tasmota", + name="Tasmota", + ) + + +async def test_discovery_update_unchanged_camera( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + caplog: pytest.LogCaptureFixture, + setup_tasmota, +) -> None: + """Test update of discovered camera.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + with patch( + "homeassistant.components.tasmota.camera.TasmotaCamera.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock, + caplog, + Platform.CAMERA, + config, + discovery_update, + object_id="tasmota", + name="Tasmota", + ) + + +async def test_discovery_device_remove( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test device registry remove.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + unique_id = f"{DEFAULT_CONFIG['mac']}_camera_camera_0" + await help_test_discovery_device_remove( + hass, mqtt_mock, Platform.CAMERA, unique_id, config + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, Platform.CAMERA, config, object_id="tasmota" + ) + + +async def test_camera_single_frame( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_tasmota, + hass_client: ClientSessionGenerator, +) -> None: + """Test single frame capture.""" + + class MockClientResponse: + def __init__(self, text) -> None: + self._text = text + + async def read(self): + return self._text + + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + + mac = config["mac"] + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + + mock_single_image_stream = Future() + mock_single_image_stream.set_result(MockClientResponse(SMALLEST_VALID_JPEG_BYTES)) + + with patch( + "hatasmota.camera.TasmotaCamera.get_still_image_stream", + return_value=mock_single_image_stream, + ): + client = await hass_client() + resp = await client.get("/api/camera_proxy/camera.tasmota") + await hass.async_block_till_done() + + assert resp.status == 200 + assert resp.content_type == "image/jpeg" + assert resp.content_length == len(SMALLEST_VALID_JPEG_BYTES) + assert await resp.read() == SMALLEST_VALID_JPEG_BYTES + + +async def test_camera_stream( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_tasmota, + hass_client: ClientSessionGenerator, +) -> None: + """Test mjpeg stream capture.""" + + class MockClientResponse: + def __init__(self, text) -> None: + self._text = text + self._frame_available = True + + async def read(self, buffer_size): + if self._frame_available: + self._frame_available = False + return self._text + return None + + def close(self): + pass + + @property + def headers(self): + return {"Content-Type": "multipart/x-mixed-replace"} + + @property + def content(self): + return self + + config = copy.deepcopy(DEFAULT_CONFIG) + config["cam"] = 1 + + mac = config["mac"] + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + + mock_mjpeg_stream = Future() + mock_mjpeg_stream.set_result(MockClientResponse(SMALLEST_VALID_JPEG_BYTES)) + + with patch( + "hatasmota.camera.TasmotaCamera.get_mjpeg_stream", + return_value=mock_mjpeg_stream, + ): + client = await hass_client() + resp = await client.get("/api/camera_proxy_stream/camera.tasmota") + await hass.async_block_till_done() + + assert resp.status == 200 + assert resp.content_type == "multipart/x-mixed-replace" + assert await resp.read() == SMALLEST_VALID_JPEG_BYTES diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 674ae316ecc..fa4a86c5004 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -36,6 +36,7 @@ DEFAULT_CONFIG = { "fn": ["Test", "Beer", "Milk", "Four", None], "hn": "tasmota_49A3BC-0956", "if": 0, # iFan + "cam": 0, # webcam "lk": 1, # RGB + white channels linked to a single light "mac": "00000049A3BC", "md": "Sonoff Basic", diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index eec2bd5ecf7..cda2583e74b 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -26,6 +26,7 @@ from homeassistant.components.telegram_bot.const import ( ATTR_AUTHENTICATION, ATTR_CALLBACK_QUERY_ID, ATTR_CAPTION, + ATTR_CHAT_ACTION, ATTR_CHAT_ID, ATTR_DISABLE_NOTIF, ATTR_DISABLE_WEB_PREV, @@ -48,6 +49,7 @@ from homeassistant.components.telegram_bot.const import ( ATTR_URL, ATTR_USERNAME, ATTR_VERIFY_SSL, + CHAT_ACTION_TYPING, CONF_CONFIG_ENTRY_ID, DOMAIN, PARSER_PLAIN_TEXT, @@ -60,6 +62,7 @@ from homeassistant.components.telegram_bot.const import ( SERVICE_EDIT_REPLYMARKUP, SERVICE_LEAVE_CHAT, SERVICE_SEND_ANIMATION, + SERVICE_SEND_CHAT_ACTION, SERVICE_SEND_DOCUMENT, SERVICE_SEND_LOCATION, SERVICE_SEND_MESSAGE, @@ -300,6 +303,37 @@ def _read_file_as_bytesio_mock(file_path): return _file +async def test_send_chat_action( + hass: HomeAssistant, + webhook_platform, + mock_broadcast_config_entry: MockConfigEntry, +) -> None: + """Test the send_chat_action service.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.send_chat_action", + AsyncMock(return_value=True), + ) as mock: + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_CHAT_ACTION, + { + CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, + ATTR_TARGET: [123456], + ATTR_CHAT_ACTION: CHAT_ACTION_TYPING, + }, + blocking=True, + return_response=True, + ) + + await hass.async_block_till_done() + mock.assert_called_once() + mock.assert_called_with(chat_id=123456, action=CHAT_ACTION_TYPING) + + @pytest.mark.parametrize( "service", [ diff --git a/tests/components/template/snapshots/test_update.ambr b/tests/components/template/snapshots/test_update.ambr new file mode 100644 index 00000000000..479ccb88ffc --- /dev/null +++ b/tests/components/template/snapshots/test_update.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/template/icon.png', + 'friendly_name': 'template_update', + 'in_progress': False, + 'installed_version': '1.0', + 'latest_version': '2.0', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.template_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index b30051a52d2..575bad4b942 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1464,7 +1464,7 @@ async def test_saving_auto_off( freezer.move_to("2022-02-02 02:02:00+00:00") fake_extra_data = { "auto_off_time": { - "__type": "", + "__type": "", "isoformat": "2022-02-02T02:02:02+00:00", }, } diff --git a/tests/components/template/test_config.py b/tests/components/template/test_config.py index b14ff0efa5a..88d6a2554f5 100644 --- a/tests/components/template/test_config.py +++ b/tests/components/template/test_config.py @@ -5,8 +5,13 @@ from __future__ import annotations import pytest import voluptuous as vol -from homeassistant.components.template.config import CONFIG_SECTION_SCHEMA +from homeassistant.components.template.config import ( + CONFIG_SECTION_SCHEMA, + async_validate_config_section, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.script_variables import ScriptVariables +from homeassistant.helpers.template import Template @pytest.mark.parametrize( @@ -48,3 +53,206 @@ async def test_invalid_schema(hass: HomeAssistant, config: dict) -> None: """Test invalid config schemas.""" with pytest.raises(vol.Invalid): CONFIG_SECTION_SCHEMA(config) + + +async def test_valid_default_entity_id(hass: HomeAssistant) -> None: + """Test valid default_entity_id schemas.""" + config = { + "button": { + "press": [], + "default_entity_id": "button.test", + }, + } + assert CONFIG_SECTION_SCHEMA(config) == { + "button": [ + { + "press": [], + "name": Template("Template Button", hass), + "default_entity_id": "button.test", + } + ] + } + + +@pytest.mark.parametrize( + "default_entity_id", + [ + "foo", + "{{ 'my_template' }}", + "SJLIVan as dfkaj;heafha faass00", + 48, + None, + "bttn.test", + ], +) +async def test_invalid_default_entity_id( + hass: HomeAssistant, default_entity_id: dict +) -> None: + """Test invalid default_entity_id schemas.""" + config = { + "button": { + "press": [], + "default_entity_id": default_entity_id, + }, + } + with pytest.raises(vol.Invalid): + CONFIG_SECTION_SCHEMA(config) + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ( + { + "variables": {"a": 1}, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "variables": {"b": 2}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + {"a": 1, "b": 2}, + ), + ( + { + "variables": {"a": 1}, + "button": [ + { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "variables": {"b": 2}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + } + ], + }, + {"a": 1, "b": 2}, + ), + ( + { + "variables": {"a": 1}, + "button": [ + { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "variables": {"a": 2, "b": 2}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + } + ], + }, + {"a": 2, "b": 2}, + ), + ( + { + "variables": {"a": 1}, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + {"a": 1}, + ), + ( + { + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "variables": {"b": 2}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + {"b": 2}, + ), + ], +) +async def test_combined_state_variables( + hass: HomeAssistant, config: dict, expected: dict +) -> None: + """Tests combining variables for state based template entities.""" + validated = await async_validate_config_section(hass, config) + assert "variables" not in validated + variables: ScriptVariables = validated["button"][0]["variables"] + assert variables.as_dict() == expected + + +@pytest.mark.parametrize( + ("config", "expected_root", "expected_entity"), + [ + ( + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "variables": {"a": 1}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.event_type }}", + "variables": {"b": 2}, + }, + }, + {"a": 1}, + {"b": 2}, + ), + ( + { + "triggers": {"trigger": "event", "event_type": "my_event"}, + "variables": {"a": 1}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.event_type }}", + }, + }, + {"a": 1}, + {}, + ), + ( + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.event_type }}", + "variables": {"b": 2}, + }, + }, + {}, + {"b": 2}, + ), + ], +) +async def test_combined_trigger_variables( + hass: HomeAssistant, + config: dict, + expected_root: dict, + expected_entity: dict, +) -> None: + """Tests variable are not combined for trigger based template entities.""" + empty = ScriptVariables({}) + validated = await async_validate_config_section(hass, config) + root_variables: ScriptVariables = validated.get("variables", empty) + assert root_variables.as_dict() == expected_root + variables: ScriptVariables = validated["binary_sensor"][0].get("variables", empty) + assert variables.as_dict() == expected_entity diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 49a9d5a1e5f..3bf7b836a8b 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -249,6 +249,16 @@ BINARY_SENSOR_OPTIONS = { {}, {}, ), + ( + "update", + {"installed_version": "{{ states('update.one') }}"}, + "off", + {"one": "2.0", "two": "1.0"}, + {}, + {"latest_version": "{{ '2.0' }}"}, + {"latest_version": "{{ '2.0' }}"}, + {}, + ), ( "vacuum", {"state": "{{ states('vacuum.one') }}"}, @@ -440,6 +450,12 @@ async def test_config_flow( {"options": "{{ ['off', 'on', 'auto'] }}"}, {"options": "{{ ['off', 'on', 'auto'] }}"}, ), + ( + "update", + {"installed_version": "{{ states('update.one') }}"}, + {"latest_version": "{{ '2.0' }}"}, + {"latest_version": "{{ '2.0' }}"}, + ), ( "vacuum", {"state": "{{ states('vacuum.one') }}"}, @@ -715,6 +731,16 @@ async def test_config_flow_device( {}, "value_template", ), + ( + "update", + {"installed_version": "{{ states('update.one') }}"}, + {"installed_version": "{{ states('update.two') }}"}, + ["off", "on"], + {"one": "2.0", "two": "1.0"}, + {"latest_version": "{{ '2.0' }}"}, + {"latest_version": "{{ '2.0' }}"}, + "installed_version", + ), ( "vacuum", {"state": "{{ states('vacuum.one') }}"}, @@ -1570,6 +1596,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "update", + {"installed_version": "{{ states('update.one') }}"}, + {"latest_version": "{{ '2.0' }}"}, + {"latest_version": "{{ '2.0' }}"}, + ), ( "vacuum", {"state": "{{ states('vacuum.one') }}"}, diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py index 574c764ba28..ec464753c28 100644 --- a/tests/components/template/test_helpers.py +++ b/tests/components/template/test_helpers.py @@ -78,177 +78,206 @@ async def test_legacy_to_modern_config( @pytest.mark.parametrize( - ("legacy_fields", "old_attr", "new_attr", "attr_template"), + ("domain", "legacy_fields", "old_attr", "new_attr", "attr_template"), [ ( + "alarm_control_panel", ALARM_CONTROL_PANEL_LEGACY_FIELDS, "value_template", "state", "{{ 1 == 1 }}", ), ( + "binary_sensor", BINARY_SENSOR_LEGACY_FIELDS, "value_template", "state", "{{ 1 == 1 }}", ), ( + "cover", COVER_LEGACY_FIELDS, "value_template", "state", "{{ 1 == 1 }}", ), ( + "cover", COVER_LEGACY_FIELDS, "position_template", "position", "{{ 100 }}", ), ( + "cover", COVER_LEGACY_FIELDS, "tilt_template", "tilt", "{{ 100 }}", ), ( + "fan", FAN_LEGACY_FIELDS, "value_template", "state", "{{ 1 == 1 }}", ), ( + "fan", FAN_LEGACY_FIELDS, "direction_template", "direction", "{{ 1 == 1 }}", ), ( + "fan", FAN_LEGACY_FIELDS, "oscillating_template", "oscillating", "{{ True }}", ), ( + "fan", FAN_LEGACY_FIELDS, "percentage_template", "percentage", "{{ 100 }}", ), ( + "fan", FAN_LEGACY_FIELDS, "preset_mode_template", "preset_mode", "{{ 'foo' }}", ), ( + "fan", LIGHT_LEGACY_FIELDS, "value_template", "state", "{{ 1 == 1 }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "rgb_template", "rgb", "{{ (255,255,255) }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "rgbw_template", "rgbw", "{{ (255,255,255,255) }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "rgbww_template", "rgbww", "{{ (255,255,255,255,255) }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "effect_list_template", "effect_list", "{{ ['a', 'b'] }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "effect_template", "effect", "{{ 'a' }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "level_template", "level", "{{ 255 }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "max_mireds_template", "max_mireds", "{{ 255 }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "min_mireds_template", "min_mireds", "{{ 255 }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "supports_transition_template", "supports_transition", "{{ True }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "temperature_template", "temperature", "{{ 255 }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "white_value_template", "white_value", "{{ 255 }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "hs_template", "hs", "{{ (255, 255) }}", ), ( + "light", LIGHT_LEGACY_FIELDS, "color_template", "hs", "{{ (255, 255) }}", ), ( + "sensor", SENSOR_LEGACY_FIELDS, "value_template", "state", "{{ 1 == 1 }}", ), ( + "sensor", SWITCH_LEGACY_FIELDS, "value_template", "state", "{{ 1 == 1 }}", ), ( + "vacuum", VACUUM_LEGACY_FIELDS, "value_template", "state", "{{ 1 == 1 }}", ), ( + "vacuum", VACUUM_LEGACY_FIELDS, "battery_level_template", "battery_level", "{{ 100 }}", ), ( + "vacuum", VACUUM_LEGACY_FIELDS, "fan_speed_template", "fan_speed", @@ -258,6 +287,7 @@ async def test_legacy_to_modern_config( ) async def test_legacy_to_modern_configs( hass: HomeAssistant, + domain: str, legacy_fields, old_attr: str, new_attr: str, @@ -274,7 +304,9 @@ async def test_legacy_to_modern_configs( old_attr: attr_template, } } - altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + altered_configs = rewrite_legacy_to_modern_configs( + hass, domain, config, legacy_fields + ) assert len(altered_configs) == 1 @@ -283,7 +315,7 @@ async def test_legacy_to_modern_configs( "availability": Template("{{ 1 == 1 }}", hass), "icon": Template("{{ 'mdi.abc' }}", hass), "name": Template("foo bar", hass), - "object_id": "foo", + "default_entity_id": f"{domain}.foo", "picture": Template("{{ 'mypicture.jpg' }}", hass), "unique_id": "foo-bar-entity", new_attr: Template(attr_template, hass), @@ -292,14 +324,15 @@ async def test_legacy_to_modern_configs( @pytest.mark.parametrize( - "legacy_fields", + ("domain", "legacy_fields"), [ - BINARY_SENSOR_LEGACY_FIELDS, - SENSOR_LEGACY_FIELDS, + ("binary_sensor", BINARY_SENSOR_LEGACY_FIELDS), + ("sensor", SENSOR_LEGACY_FIELDS), ], ) async def test_friendly_name_template_legacy_to_modern_configs( hass: HomeAssistant, + domain: str, legacy_fields, ) -> None: """Test the conversion of friendly_name_tempalte in legacy template to modern template.""" @@ -312,7 +345,9 @@ async def test_friendly_name_template_legacy_to_modern_configs( "friendly_name_template": "{{ 'foo bar' }}", } } - altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + altered_configs = rewrite_legacy_to_modern_configs( + hass, domain, config, legacy_fields + ) assert len(altered_configs) == 1 @@ -320,7 +355,7 @@ async def test_friendly_name_template_legacy_to_modern_configs( { "availability": Template("{{ 1 == 1 }}", hass), "icon": Template("{{ 'mdi.abc' }}", hass), - "object_id": "foo", + "default_entity_id": f"{domain}.foo", "picture": Template("{{ 'mypicture.jpg' }}", hass), "unique_id": "foo-bar-entity", "name": Template("{{ 'foo bar' }}", hass), diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 8efca13a218..a95bf2a6332 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -376,6 +376,18 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: "event_types": "{{ ['single', 'double'] }}", }, ), + ( + { + "template_type": "update", + "name": "My template", + "latest_version": "{{ '1.0' }}", + "installed_version": "{{ '1.0' }}", + }, + { + "latest_version": "{{ '1.0' }}", + "installed_version": "{{ '1.0' }}", + }, + ), ], ) async def test_change_device( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 9aba8511192..0a940d111c5 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -2298,6 +2298,65 @@ async def test_trigger_action(hass: HomeAssistant) -> None: assert events[0].context.parent_id == context.id +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"a": "{{ trigger.event.data.a }}"}, + "action": [ + { + "variables": {"b": "{{ a + 1 }}"}, + }, + {"event": "test_event2", "event_data": {"hello": "world"}}, + ], + "sensor": [ + { + "name": "Hello Name", + "state": "{{ a + b + c }}", + "variables": {"c": "{{ b + 1 }}"}, + "attributes": { + "a": "{{ a }}", + "b": "{{ b }}", + "c": "{{ c }}", + }, + } + ], + }, + ], + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_trigger_action_variables(hass: HomeAssistant) -> None: + """Test trigger entity with variables in an action works.""" + event = "test_event2" + context = Context() + events = async_capture_events(hass, event) + + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"a": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == str(1 + 2 + 3) + assert state.context is context + assert state.attributes["a"] == 1 + assert state.attributes["b"] == 2 + assert state.attributes["c"] == 3 + + assert len(events) == 1 + assert events[0].context.parent_id == context.id + + @pytest.mark.parametrize(("count", "domain"), [(1, template.DOMAIN)]) @pytest.mark.parametrize( "config", diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index 7fe3870ae1e..5b82af91271 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -18,3 +18,23 @@ async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: entity.add_template_attribute("_hello", tpl_with_hass) assert len(entity._template_attrs.get(tpl_with_hass, [])) == 1 + + +async def test_default_entity_id(hass: HomeAssistant) -> None: + """Test template entity creates suggested entity_id from the default_entity_id.""" + + class TemplateTest(template_entity.TemplateEntity): + _entity_id_format = "test.{}" + + entity = TemplateTest(hass, {"default_entity_id": "test.test"}, "a") + assert entity.entity_id == "test.test" + + +async def test_bad_default_entity_id(hass: HomeAssistant) -> None: + """Test template entity creates suggested entity_id from the default_entity_id.""" + + class TemplateTest(template_entity.TemplateEntity): + _entity_id_format = "test.{}" + + entity = TemplateTest(hass, {"default_entity_id": "bad.test"}, "a") + assert entity.entity_id == "test.test" diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index 65db69fa2b9..22201ab5ca9 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -7,6 +7,7 @@ from homeassistant.components.template.coordinator import TriggerUpdateCoordinat from homeassistant.const import CONF_ICON, CONF_NAME, CONF_STATE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import template +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.trigger_template_entity import CONF_PICTURE _ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}' @@ -17,6 +18,7 @@ class TestEntity(trigger_entity.TriggerEntity): """Test entity class.""" __test__ = False + _entity_id_format = "test.{}" extra_template_keys = (CONF_STATE,) @property @@ -122,15 +124,53 @@ async def test_template_state_syntax_error( async def test_script_variables_from_coordinator(hass: HomeAssistant) -> None: """Test script variables.""" + + hass.states.async_set("sensor.test", "1") + + coordinator = TriggerUpdateCoordinator( + hass, + { + "variables": ScriptVariables( + {"a": template.Template("{{ states('sensor.test') }}", hass), "c": 0} + ) + }, + ) + entity = TestEntity( + hass, + coordinator, + { + "state": template.Template("{{ 'on' }}", hass), + "variables": ScriptVariables( + {"b": template.Template("{{ a + 1 }}", hass), "c": 1} + ), + }, + ) + await coordinator._handle_triggered({}) + entity._process_data() + assert entity._render_script_variables() == {"a": 1, "b": 2, "c": 1} + + hass.states.async_set("sensor.test", "2") + + await coordinator._handle_triggered({"value": STATE_ON}) + entity._process_data() + + assert entity._render_script_variables() == { + "value": STATE_ON, + "a": 2, + "b": 3, + "c": 1, + } + + +async def test_default_entity_id(hass: HomeAssistant) -> None: + """Test template entity creates suggested entity_id from the default_entity_id.""" coordinator = TriggerUpdateCoordinator(hass, {}) - entity = TestEntity(hass, coordinator, {}) + entity = TestEntity(hass, coordinator, {"default_entity_id": "test.test"}) + assert entity.entity_id == "test.test" - assert entity._render_script_variables() == {} - coordinator.data = {"run_variables": None} - - assert entity._render_script_variables() == {} - - coordinator._execute_update({"value": STATE_ON}) - - assert entity._render_script_variables() == {"value": STATE_ON} +async def test_bad_default_entity_id(hass: HomeAssistant) -> None: + """Test template entity creates suggested entity_id from the default_entity_id.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, {"default_entity_id": "bad.test"}) + assert entity.entity_id == "test.test" diff --git a/tests/components/template/test_update.py b/tests/components/template/test_update.py new file mode 100644 index 00000000000..61fbfeede7a --- /dev/null +++ b/tests/components/template/test_update.py @@ -0,0 +1,1085 @@ +"""The tests for the Template update platform.""" + +from typing import Any + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import template, update +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_ICON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, ServiceCall, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ( + ConfigurationStyle, + async_get_flow_preview_state, + async_setup_modern_state_format, + async_setup_modern_trigger_format, + make_test_trigger, +) + +from tests.common import ( + MockConfigEntry, + assert_setup_component, + mock_restore_cache_with_extra_data, +) +from tests.conftest import WebSocketGenerator + +TEST_OBJECT_ID = "template_update" +TEST_ENTITY_ID = f"update.{TEST_OBJECT_ID}" +TEST_INSTALLED_SENSOR = "sensor.installed_update" +TEST_LATEST_SENSOR = "sensor.latest_update" +TEST_SENSOR_ID = "sensor.test_update" +TEST_STATE_TRIGGER = make_test_trigger( + TEST_INSTALLED_SENSOR, TEST_LATEST_SENSOR, TEST_SENSOR_ID +) +TEST_INSTALLED_TEMPLATE = "{{ '1.0' }}" +TEST_LATEST_TEMPLATE = "{{ '2.0' }}" + +TEST_UPDATE_CONFIG = { + "installed_version": TEST_INSTALLED_TEMPLATE, + "latest_version": TEST_LATEST_TEMPLATE, +} +TEST_UNIQUE_ID_CONFIG = { + **TEST_UPDATE_CONFIG, + "unique_id": "not-so-unique-anymore", +} + +INSTALL_ACTION = { + "install": { + "action": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "install", + "backup": "{{ backup }}", + "specific_version": "{{ specific_version }}", + }, + } +} + + +async def async_setup_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: dict[str, Any], + extra_config: dict[str, Any] | None, +) -> None: + """Do setup of update integration.""" + config = {**config, **extra_config} if extra_config else config + if style == ConfigurationStyle.MODERN: + await async_setup_modern_state_format(hass, update.DOMAIN, count, config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_modern_trigger_format( + hass, update.DOMAIN, TEST_STATE_TRIGGER, count, config + ) + + +@pytest.fixture +async def setup_base( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: dict[str, Any], +) -> None: + """Do setup of update integration.""" + await async_setup_config( + hass, + count, + style, + config, + None, + ) + + +@pytest.fixture +async def setup_update( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + installed_template: str, + latest_template: str, + extra_config: dict[str, Any] | None, +) -> None: + """Do setup of update integration.""" + await async_setup_config( + hass, + count, + style, + { + "name": TEST_OBJECT_ID, + "installed_version": installed_template, + "latest_version": latest_template, + }, + extra_config, + ) + + +@pytest.fixture +async def setup_single_attribute_update( + hass: HomeAssistant, + style: ConfigurationStyle, + installed_template: str, + latest_template: str, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of update platform testing a single attribute.""" + await async_setup_config( + hass, + 1, + style, + { + "name": TEST_OBJECT_ID, + "installed_version": installed_template, + "latest_version": latest_template, + }, + {attribute: attribute_template} if attribute and attribute_template else {}, + ) + + +async def test_legacy_platform_config(hass: HomeAssistant) -> None: + """Test a legacy platform does not create update entities.""" + with assert_setup_component(1, update.DOMAIN): + assert await async_setup_component( + hass, + update.DOMAIN, + {"update": {"platform": "template", "updates": {TEST_OBJECT_ID: {}}}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + assert hass.states.async_all("update") == [] + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the config flow.""" + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": TEST_OBJECT_ID, + "template_type": update.DOMAIN, + **TEST_UPDATE_CONFIG, + }, + title="My template", + ) + 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() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state == snapshot + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for device for Template.""" + + device_config_entry = MockConfigEntry() + device_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=device_config_entry.entry_id, + identifiers={("test", "identifier_test")}, + connections={("mac", "30:31:32:33:34:35")}, + ) + await hass.async_block_till_done() + assert device_entry is not None + assert device_entry.id is not None + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": TEST_OBJECT_ID, + "template_type": update.DOMAIN, + **TEST_UPDATE_CONFIG, + "device_id": device_entry.id, + }, + title="My template", + ) + 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() + + template_entity = entity_registry.async_get(TEST_ENTITY_ID) + assert template_entity is not None + assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize(("count", "extra_config"), [(1, None)]) +@pytest.mark.parametrize( + ("style", "expected_state"), + [ + (ConfigurationStyle.MODERN, STATE_UNKNOWN), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.parametrize( + ("installed_template", "latest_template"), + [ + ("{{states.test['big.fat...']}}", TEST_LATEST_TEMPLATE), + (TEST_INSTALLED_TEMPLATE, "{{states.test['big.fat...']}}"), + ("{{states.test['big.fat...']}}", "{{states.test['big.fat...']}}"), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_syntax_error( + hass: HomeAssistant, + expected_state: str, +) -> None: + """Test template update with render error.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("count", "extra_config", "installed_template", "latest_template"), + [ + ( + 1, + None, + "{{ states('sensor.installed_update') }}", + "{{ states('sensor.latest_update') }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("installed", "latest", "expected"), + [ + ("1.0", "2.0", STATE_ON), + ("2.0", "2.0", STATE_OFF), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_update_templates( + hass: HomeAssistant, installed: str, latest: str, expected: str +) -> None: + """Test update template.""" + hass.states.async_set(TEST_INSTALLED_SENSOR, installed) + hass.states.async_set(TEST_LATEST_SENSOR, latest) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + assert state.attributes["installed_version"] == installed + assert state.attributes["latest_version"] == latest + + # ensure that the entity picture exists when not provided. + assert ( + state.attributes["entity_picture"] + == "https://brands.home-assistant.io/_/template/icon.png" + ) + + +@pytest.mark.parametrize( + ("count", "extra_config", "installed_template", "latest_template"), + [ + ( + 1, + None, + "{{ states('sensor.installed_update') }}", + "{{ states('sensor.latest_update') }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_update") +async def test_installed_and_latest_template_updates_from_entity( + hass: HomeAssistant, +) -> None: + """Test template installed and latest version templates updates from entities.""" + hass.states.async_set(TEST_INSTALLED_SENSOR, "1.0") + hass.states.async_set(TEST_LATEST_SENSOR, "2.0") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes["installed_version"] == "1.0" + assert state.attributes["latest_version"] == "2.0" + + hass.states.async_set(TEST_INSTALLED_SENSOR, "2.0") + hass.states.async_set(TEST_LATEST_SENSOR, "2.0") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes["installed_version"] == "2.0" + assert state.attributes["latest_version"] == "2.0" + + hass.states.async_set(TEST_INSTALLED_SENSOR, "2.0") + hass.states.async_set(TEST_LATEST_SENSOR, "3.0") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes["installed_version"] == "2.0" + assert state.attributes["latest_version"] == "3.0" + + +@pytest.mark.parametrize( + ("count", "extra_config", "latest_template"), + [(1, None, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("installed_template", "expected", "expected_attr"), + [ + ("{{ '1.0' }}", STATE_ON, "1.0"), + ("{{ 1.0 }}", STATE_ON, "1.0"), + ("{{ '2.0' }}", STATE_OFF, "2.0"), + ("{{ 2.0 }}", STATE_OFF, "2.0"), + ("{{ None }}", STATE_UNKNOWN, None), + ("{{ 'foo' }}", STATE_ON, "foo"), + ("{{ x + 2 }}", STATE_UNKNOWN, None), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_installed_version_template( + hass: HomeAssistant, expected: str, expected_attr: Any +) -> None: + """Test installed_version template results.""" + # Ensure trigger based template entities update + hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + assert state.attributes["installed_version"] == expected_attr + + +@pytest.mark.parametrize( + ("count", "extra_config", "installed_template"), + [(1, None, TEST_INSTALLED_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("latest_template", "expected", "expected_attr"), + [ + ("{{ '1.0' }}", STATE_OFF, "1.0"), + ("{{ 1.0 }}", STATE_OFF, "1.0"), + ("{{ '2.0' }}", STATE_ON, "2.0"), + ("{{ 2.0 }}", STATE_ON, "2.0"), + ("{{ None }}", STATE_UNKNOWN, None), + ("{{ 'foo' }}", STATE_ON, "foo"), + ("{{ x + 2 }}", STATE_UNKNOWN, None), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_latest_version_template( + hass: HomeAssistant, expected: str, expected_attr: Any +) -> None: + """Test latest_version template results.""" + # Ensure trigger based template entities update + hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + assert state.attributes["latest_version"] == expected_attr + + +@pytest.mark.parametrize( + ("count", "extra_config", "installed_template", "latest_template"), + [ + ( + 1, + INSTALL_ACTION, + "{{ states('sensor.installed_update') }}", + "{{ states('sensor.latest_update') }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_update") +async def test_install_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test install action.""" + + hass.states.async_set(TEST_INSTALLED_SENSOR, "1.0") + hass.states.async_set(TEST_LATEST_SENSOR, "2.0") + await hass.async_block_till_done() + + await hass.services.async_call( + update.DOMAIN, + update.SERVICE_INSTALL, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + assert calls[-1].data["action"] == "install" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + hass.states.async_set(TEST_INSTALLED_SENSOR, "2.0") + hass.states.async_set(TEST_LATEST_SENSOR, "2.0") + await hass.async_block_till_done() + + # Ensure an error is raised when there's no update. + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + update.DOMAIN, + update.SERVICE_INSTALL, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + assert calls[-1].data["action"] == "install" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize( + ("installed_template", "latest_template"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute", "attribute_template", "key", "expected"), + [ + ( + "picture", + "{% if is_state('sensor.installed_update', 'on') %}something{% endif %}", + ATTR_ENTITY_PICTURE, + "something", + ), + ( + "icon", + "{% if is_state('sensor.installed_update', 'on') %}mdi:something{% endif %}", + ATTR_ICON, + "mdi:something", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_entity_picture_and_icon_templates( + hass: HomeAssistant, key: str, expected: str +) -> None: + """Test picture and icon template.""" + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(key) in ("", None) + + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + + assert state.attributes[key] == expected + + +@pytest.mark.parametrize( + ("installed_template", "latest_template"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute", "attribute_template"), + [ + ( + "picture", + "{{ 'foo.png' if is_state('sensor.installed_update', 'on') else None }}", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_entity_picture_uses_default(hass: HomeAssistant) -> None: + """Test entity picture when template resolves None.""" + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes[ATTR_ENTITY_PICTURE] == "foo.png" + + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + + assert ( + state.attributes[ATTR_ENTITY_PICTURE] + == "https://brands.home-assistant.io/_/template/icon.png" + ) + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE, "in_progress")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute_template", "expected", "error"), + [ + ("{{ True }}", True, None), + ("{{ False }}", False, None), + ("{{ None }}", False, "Received invalid in_process value: None"), + ( + "{{ 'foo' }}", + False, + "Received invalid in_process value: foo", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_in_process_template( + hass: HomeAssistant, + attribute: str, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, + caplog_setup_text: str, +) -> None: + """Test in process templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(attribute) == expected + + assert error is None or error in caplog_setup_text or error in caplog.text + + +@pytest.mark.parametrize( + ( + "installed_template", + "latest_template", + ), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize("attribute", ["release_summary", "title"]) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ True }}", "True"), + ("{{ False }}", "False"), + ("{{ None }}", None), + ("{{ 'foo' }}", "foo"), + ("{{ 1.0 }}", "1.0"), + ("{{ x + 2 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_release_summary_and_title_templates( + hass: HomeAssistant, + attribute: str, + expected: Any, +) -> None: + """Test release summary and title templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE, "release_url")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute_template", "expected", "error"), + [ + ("{{ 'http://foo.bar' }}", "http://foo.bar", None), + ("{{ 'https://foo.bar' }}", "https://foo.bar", None), + ("{{ None }}", None, None), + ( + "{{ '/local/thing' }}", + None, + "Received invalid release_url: /local/thing", + ), + ( + "{{ 'foo' }}", + None, + "Received invalid release_url: foo", + ), + ( + "{{ 1.0 }}", + None, + "Received invalid release_url: 1", + ), + ( + "{{ True }}", + None, + "Received invalid release_url: True", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_release_url_template( + hass: HomeAssistant, + attribute: str, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, + caplog_setup_text: str, +) -> None: + """Test release url templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(attribute) == expected + + assert error is None or error in caplog_setup_text or error in caplog.text + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE, "update_percentage")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute_template", "expected", "error"), + [ + ("{{ 100 }}", 100, None), + ("{{ 0 }}", 0, None), + ("{{ 45 }}", 45, None), + ("{{ None }}", None, None), + ("{{ -1 }}", None, "Received invalid update_percentage: -1"), + ("{{ 101 }}", None, "Received invalid update_percentage: 101"), + ("{{ 'foo' }}", None, "Received invalid update_percentage: foo"), + ("{{ x - 4 }}", None, "UndefinedError: 'x' is undefined"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_update_percent_template( + hass: HomeAssistant, + attribute: str, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, + caplog_setup_text: str, +) -> None: + """Test update percent templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(attribute) == expected + + assert error is None or error in caplog_setup_text or error in caplog.text + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute", "attribute_template"), + [ + ( + TEST_INSTALLED_TEMPLATE, + TEST_LATEST_TEMPLATE, + "update_percentage", + "{% set e = 'sensor.test_update' %}{{ states(e) if e | has_value else None }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_optimistic_in_progress_with_update_percent_template( + hass: HomeAssistant, +) -> None: + """Test optimistic in_progress attribute with update percent templates.""" + # Ensure trigger entities trigger. + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + + for i in range(101): + state = hass.states.async_set(TEST_SENSOR_ID, i) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == i + + state = hass.states.async_set(TEST_SENSOR_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + + +@pytest.mark.parametrize( + ( + "count", + "installed_template", + "latest_template", + ), + [(1, TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ( + "extra_config", + "supported_feature", + "action_data", + "expected_backup", + "expected_version", + ), + [ + ( + {"backup": True, **INSTALL_ACTION}, + update.UpdateEntityFeature.BACKUP | update.UpdateEntityFeature.INSTALL, + {"backup": True}, + True, + None, + ), + ( + {"specific_version": True, **INSTALL_ACTION}, + update.UpdateEntityFeature.SPECIFIC_VERSION + | update.UpdateEntityFeature.INSTALL, + {"version": "v2.0"}, + False, + "v2.0", + ), + ( + {"backup": True, "specific_version": True, **INSTALL_ACTION}, + update.UpdateEntityFeature.SPECIFIC_VERSION + | update.UpdateEntityFeature.BACKUP + | update.UpdateEntityFeature.INSTALL, + {"backup": True, "version": "v2.0"}, + True, + "v2.0", + ), + (INSTALL_ACTION, update.UpdateEntityFeature.INSTALL, {}, False, None), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_supported_features( + hass: HomeAssistant, + supported_feature: update.UpdateEntityFeature, + action_data: dict, + calls: list[ServiceCall], + expected_backup: bool, + expected_version: str | None, +) -> None: + """Test release summary and title templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["supported_features"] == supported_feature + + await hass.services.async_call( + update.DOMAIN, + update.SERVICE_INSTALL, + {"entity_id": TEST_ENTITY_ID, **action_data}, + blocking=True, + ) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + data = calls[-1].data + assert data["action"] == "install" + assert data["caller"] == TEST_ENTITY_ID + assert data["backup"] == expected_backup + assert data["specific_version"] == expected_version + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute", "attribute_template"), + [ + ( + TEST_INSTALLED_TEMPLATE, + TEST_LATEST_TEMPLATE, + "availability", + "{{ 'sensor.test_update' | has_value }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_available_template_with_entities(hass: HomeAssistant) -> None: + """Test availability templates with values from other entities.""" + hass.states.async_set(TEST_SENSOR_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + hass.states.async_set(TEST_SENSOR_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_SENSOR_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute", "attribute_template"), + [ + ( + TEST_INSTALLED_TEMPLATE, + TEST_LATEST_TEMPLATE, + "availability", + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_invalid_availability_template_keeps_component_available( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + caplog_setup_text, +) -> None: + """Test that an invalid availability keeps the device available.""" + # Ensure entity triggers + hass.states.async_set(TEST_SENSOR_ID, "anything") + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + + error = "UndefinedError: 'x' is undefined" + assert error in caplog_setup_text or error in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "update": { + "name": TEST_OBJECT_ID, + "installed_version": "{{ trigger.event.data.action }}", + "latest_version": "{{ '1.0.2' }}", + "picture": "{{ '/local/dogs.png' }}", + "icon": "{{ 'mdi:pirate' }}", + }, + }, + }, + ], +) +async def test_trigger_entity_restore_state( + hass: HomeAssistant, + count: int, + domain: str, + config: dict, +) -> None: + """Test restoring trigger entities.""" + restored_attributes = { + "installed_version": "1.0.0", + "latest_version": "1.0.1", + "entity_picture": "/local/cats.png", + "icon": "mdi:ship", + "skipped_version": "1.0.1", + } + fake_state = State( + TEST_ENTITY_ID, + STATE_OFF, + restored_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((fake_state, {}),)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + for attr, value in restored_attributes.items(): + assert state.attributes[attr] == value + + hass.bus.async_fire("test_event", {"action": "1.0.0"}) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes["icon"] == "mdi:pirate" + assert state.attributes["entity_picture"] == "/local/dogs.png" + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("updates", "style"), + [ + ( + [ + { + "name": "test_template_event_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_event_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ( + [ + { + "name": "test_template_event_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_event_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), + ], +) +async def test_unique_id( + hass: HomeAssistant, count: int, updates: list[dict], style: ConfigurationStyle +) -> None: + """Test unique_id option only creates one update entity per id.""" + config = {"update": updates} + if style == ConfigurationStyle.TRIGGER: + config = {**config, **TEST_STATE_TRIGGER} + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + {"template": config}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("update")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test unique_id option creates one update entity per nested id.""" + + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "update": [ + { + "name": "test_a", + **TEST_UPDATE_CONFIG, + "unique_id": "a", + }, + { + "name": "test_b", + **TEST_UPDATE_CONFIG, + "unique_id": "b", + }, + ], + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("update")) == 2 + + entry = entity_registry.async_get("update.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("update.test_b") + assert entry + assert entry.unique_id == "x-b" + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + update.DOMAIN, + {"name": "My template", **TEST_UPDATE_CONFIG}, + ) + + assert state["state"] == STATE_ON + assert state["attributes"]["installed_version"] == "1.0" + assert state["attributes"]["latest_version"] == "2.0" diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index f6268627be1..eab1441399f 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_charged-statealt] @@ -130,7 +130,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_discharged-statealt] @@ -205,7 +205,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30.06', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_exported-statealt] @@ -280,7 +280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] @@ -355,7 +355,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.08', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] @@ -430,7 +430,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '43.6', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] @@ -580,7 +580,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30.022', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] @@ -655,7 +655,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] @@ -730,7 +730,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.282', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] @@ -805,7 +805,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30.96', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] @@ -955,7 +955,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.001', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_generator_exported-statealt] @@ -1105,7 +1105,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported-statealt] @@ -1180,7 +1180,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.048', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] @@ -1255,7 +1255,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] @@ -1330,7 +1330,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '127.32', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] @@ -1405,7 +1405,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.542', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_imported-statealt] @@ -1555,7 +1555,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0106171875', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_exported-statealt] @@ -1630,7 +1630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0450625', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_imported-statealt] @@ -1757,7 +1757,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Grid Status', + 'original_name': 'Grid status', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1771,7 +1771,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Energy Site Grid Status', + 'friendly_name': 'Energy Site Grid status', 'options': list([ 'island_status_unknown', 'on_grid', @@ -1792,7 +1792,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Energy Site Grid Status', + 'friendly_name': 'Energy Site Grid status', 'options': list([ 'island_status_unknown', 'on_grid', @@ -1865,7 +1865,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_home_usage-statealt] @@ -2087,7 +2087,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '211.88', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_exported-statealt] @@ -2162,7 +2162,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_generated-statealt] diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index 6f700f7e939..49d9f48a841 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -445,7 +445,7 @@ async def test_climate_notemp( 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( CLIMATE_DOMAIN, diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 7bd90a3568c..3645a0f434d 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -317,18 +317,26 @@ async def test_energy_site_refresh_error( # Test Energy History Coordinator -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +@pytest.mark.parametrize(("side_effect"), [side_effect for side_effect, _ in ERRORS]) async def test_energy_history_refresh_error( hass: HomeAssistant, normal_config_entry: MockConfigEntry, mock_energy_history: AsyncMock, side_effect: TeslaFleetError, - state: ConfigEntryState, + freezer: FrozenDateTimeFactory, ) -> None: """Test coordinator refresh with an error.""" - mock_energy_history.side_effect = side_effect await setup_platform(hass, normal_config_entry) - assert normal_config_entry.state is state + assert normal_config_entry.state is ConfigEntryState.LOADED + + # Now test that the coordinator handles errors during refresh + mock_energy_history.side_effect = side_effect + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # The coordinator should handle the error gracefully + assert normal_config_entry.state is ConfigEntryState.LOADED async def test_energy_live_refresh_ratelimited( @@ -410,20 +418,20 @@ async def test_energy_history_refresh_ratelimited( async_fire_time_changed(hass) await hass.async_block_till_done() - assert mock_energy_history.call_count == 2 + assert mock_energy_history.call_count == 1 freezer.tick(ENERGY_HISTORY_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() # Should not call for another 10 seconds - assert mock_energy_history.call_count == 2 + assert mock_energy_history.call_count == 1 freezer.tick(ENERGY_HISTORY_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert mock_energy_history.call_count == 3 + assert mock_energy_history.call_count == 2 async def test_init_region_issue( diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 7feefdafedf..1f9561ac4c7 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -3,6 +3,8 @@ from ipaddress import ip_address from unittest.mock import patch +import pytest + from homeassistant.components import thread from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -56,14 +58,18 @@ async def test_import(hass: HomeAssistant) -> None: assert config_entry.unique_id is None -async def test_import_then_zeroconf(hass: HomeAssistant) -> None: - """Test the import flow.""" +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_single_instance_allowed_zeroconf( + hass: HomeAssistant, + source: str, +) -> None: + """Test zeroconf single instance allowed abort reason.""" with patch( "homeassistant.components.thread.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - thread.DOMAIN, context={"source": "import"} + thread.DOMAIN, context={"source": source} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -77,7 +83,7 @@ async def test_import_then_zeroconf(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" assert len(mock_setup_entry.mock_calls) == 0 @@ -152,8 +158,45 @@ async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_then_import(hass: HomeAssistant) -> None: - """Test the import flow.""" +@pytest.mark.parametrize( + ("first_source", "second_source"), [("import", "user"), ("user", "import")] +) +async def test_import_and_user( + hass: HomeAssistant, + first_source: str, + second_source: str, +) -> None: + """Test single instance allowed for user and import.""" + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": first_source} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": second_source} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_zeroconf_then_import_user( + hass: HomeAssistant, + source: str, +) -> None: + """Test single instance allowed abort reason for import/user flow.""" result = await hass.config_entries.flow.async_init( thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD ) @@ -169,9 +212,37 @@ async def test_zeroconf_then_import(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - thread.DOMAIN, context={"source": "import"} + thread.DOMAIN, context={"source": source} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_zeroconf_in_progress_then_import_user( + hass: HomeAssistant, + source: str, +) -> None: + """Test priority (import/user) flow with zeroconf flow in progress.""" + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": source} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_setup_entry.call_count == 1 + + flows_in_progress = hass.config_entries.flow.async_progress() + assert len(flows_in_progress) == 0 diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index fed35bc6502..0fc480db37a 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -202,6 +202,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "entity_id": "sensor.changed"} ) + hass.config_entries.async_schedule_reload(config_entry.entry_id) await hass.async_block_till_done() # Check that the device association has updated diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py index dc6f5d2789d..9c9fb86f917 100644 --- a/tests/components/tibber/test_services.py +++ b/tests/components/tibber/test_services.py @@ -88,24 +88,20 @@ async def test_get_prices( { "start_time": START_TIME.isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, { "start_time": (START_TIME + dt.timedelta(hours=1)).isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, ], "second_home": [ { "start_time": START_TIME.isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, { "start_time": (START_TIME + dt.timedelta(hours=1)).isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, ], } @@ -138,24 +134,20 @@ async def test_get_prices_start_tomorrow( { "start_time": tomorrow.isoformat(), "price": 0.46914, - "level": "VERY_EXPENSIVE", }, { "start_time": (tomorrow + dt.timedelta(hours=1)).isoformat(), "price": 0.46914, - "level": "VERY_EXPENSIVE", }, ], "second_home": [ { "start_time": tomorrow.isoformat(), "price": 0.46914, - "level": "VERY_EXPENSIVE", }, { "start_time": (tomorrow + dt.timedelta(hours=1)).isoformat(), "price": 0.46914, - "level": "VERY_EXPENSIVE", }, ], } @@ -197,24 +189,20 @@ async def test_get_prices_with_timezones( { "start_time": START_TIME.isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, { "start_time": (START_TIME + dt.timedelta(hours=1)).isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, ], "second_home": [ { "start_time": START_TIME.isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, { "start_time": (START_TIME + dt.timedelta(hours=1)).isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, ], } diff --git a/tests/components/togrill/conftest.py b/tests/components/togrill/conftest.py index 6b028ca5270..c58bc0698a9 100644 --- a/tests/components/togrill/conftest.py +++ b/tests/components/togrill/conftest.py @@ -57,9 +57,18 @@ def mock_client(enable_bluetooth: None, mock_client_class: Mock) -> Generator[Mo client_object.mocked_notify = None async def _connect( - address: str, callback: Callable[[Packet], None] | None = None + address: str, + callback: Callable[[Packet], None] | None = None, + disconnected_callback: Callable[[], None] | None = None, ) -> Mock: client_object.mocked_notify = callback + if disconnected_callback: + + def _disconnected_callback(): + client_object.is_connected = False + disconnected_callback() + + client_object.mocked_disconnected_callback = _disconnected_callback return client_object async def _disconnect() -> None: diff --git a/tests/components/togrill/snapshots/test_event.ambr b/tests/components/togrill/snapshots/test_event.ambr index 99908cd85c2..e579c0745bc 100644 --- a/tests/components/togrill/snapshots/test_event.ambr +++ b/tests/components/togrill/snapshots/test_event.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup[no_data][event.pro_05_probe_1-entry] +# name: test_setup[no_data][event.probe_1_event-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -27,7 +27,7 @@ 'disabled_by': None, 'domain': 'event', 'entity_category': None, - 'entity_id': 'event.pro_05_probe_1', + 'entity_id': 'event.probe_1_event', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -39,7 +39,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Probe 1', + 'original_name': 'Event', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -49,7 +49,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup[no_data][event.pro_05_probe_1-state] +# name: test_setup[no_data][event.probe_1_event-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'event_type': None, @@ -67,17 +67,17 @@ 'ambient_cool_down', 'probe_timer_alarm', ]), - 'friendly_name': 'Pro-05 Probe 1', + 'friendly_name': 'Probe 1 Event', }), 'context': , - 'entity_id': 'event.pro_05_probe_1', + 'entity_id': 'event.probe_1_event', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup[no_data][event.pro_05_probe_2-entry] +# name: test_setup[no_data][event.probe_2_event-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -105,7 +105,7 @@ 'disabled_by': None, 'domain': 'event', 'entity_category': None, - 'entity_id': 'event.pro_05_probe_2', + 'entity_id': 'event.probe_2_event', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -117,7 +117,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Probe 2', + 'original_name': 'Event', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -127,7 +127,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup[no_data][event.pro_05_probe_2-state] +# name: test_setup[no_data][event.probe_2_event-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'event_type': None, @@ -145,17 +145,17 @@ 'ambient_cool_down', 'probe_timer_alarm', ]), - 'friendly_name': 'Pro-05 Probe 2', + 'friendly_name': 'Probe 2 Event', }), 'context': , - 'entity_id': 'event.pro_05_probe_2', + 'entity_id': 'event.probe_2_event', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup[non_event_packet][event.pro_05_probe_1-entry] +# name: test_setup[non_event_packet][event.probe_1_event-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -183,7 +183,7 @@ 'disabled_by': None, 'domain': 'event', 'entity_category': None, - 'entity_id': 'event.pro_05_probe_1', + 'entity_id': 'event.probe_1_event', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -195,7 +195,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Probe 1', + 'original_name': 'Event', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -205,7 +205,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup[non_event_packet][event.pro_05_probe_1-state] +# name: test_setup[non_event_packet][event.probe_1_event-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'event_type': None, @@ -223,17 +223,17 @@ 'ambient_cool_down', 'probe_timer_alarm', ]), - 'friendly_name': 'Pro-05 Probe 1', + 'friendly_name': 'Probe 1 Event', }), 'context': , - 'entity_id': 'event.pro_05_probe_1', + 'entity_id': 'event.probe_1_event', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup[non_event_packet][event.pro_05_probe_2-entry] +# name: test_setup[non_event_packet][event.probe_2_event-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -261,7 +261,7 @@ 'disabled_by': None, 'domain': 'event', 'entity_category': None, - 'entity_id': 'event.pro_05_probe_2', + 'entity_id': 'event.probe_2_event', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -273,7 +273,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Probe 2', + 'original_name': 'Event', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -283,7 +283,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup[non_event_packet][event.pro_05_probe_2-state] +# name: test_setup[non_event_packet][event.probe_2_event-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'event_type': None, @@ -301,17 +301,17 @@ 'ambient_cool_down', 'probe_timer_alarm', ]), - 'friendly_name': 'Pro-05 Probe 2', + 'friendly_name': 'Probe 2 Event', }), 'context': , - 'entity_id': 'event.pro_05_probe_2', + 'entity_id': 'event.probe_2_event', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup[non_known_message][event.pro_05_probe_1-entry] +# name: test_setup[non_known_message][event.probe_1_event-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -339,7 +339,7 @@ 'disabled_by': None, 'domain': 'event', 'entity_category': None, - 'entity_id': 'event.pro_05_probe_1', + 'entity_id': 'event.probe_1_event', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -351,7 +351,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Probe 1', + 'original_name': 'Event', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -361,7 +361,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup[non_known_message][event.pro_05_probe_1-state] +# name: test_setup[non_known_message][event.probe_1_event-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'event_type': None, @@ -379,17 +379,17 @@ 'ambient_cool_down', 'probe_timer_alarm', ]), - 'friendly_name': 'Pro-05 Probe 1', + 'friendly_name': 'Probe 1 Event', }), 'context': , - 'entity_id': 'event.pro_05_probe_1', + 'entity_id': 'event.probe_1_event', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup[non_known_message][event.pro_05_probe_2-entry] +# name: test_setup[non_known_message][event.probe_2_event-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -417,7 +417,7 @@ 'disabled_by': None, 'domain': 'event', 'entity_category': None, - 'entity_id': 'event.pro_05_probe_2', + 'entity_id': 'event.probe_2_event', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -429,7 +429,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Probe 2', + 'original_name': 'Event', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -439,7 +439,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup[non_known_message][event.pro_05_probe_2-state] +# name: test_setup[non_known_message][event.probe_2_event-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'event_type': None, @@ -457,10 +457,10 @@ 'ambient_cool_down', 'probe_timer_alarm', ]), - 'friendly_name': 'Pro-05 Probe 2', + 'friendly_name': 'Probe 2 Event', }), 'context': , - 'entity_id': 'event.pro_05_probe_2', + 'entity_id': 'event.probe_2_event', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/togrill/snapshots/test_init.ambr b/tests/components/togrill/snapshots/test_init.ambr index b461d103e73..e4208e702cc 100644 --- a/tests/components/togrill/snapshots/test_init.ambr +++ b/tests/components/togrill/snapshots/test_init.ambr @@ -16,6 +16,10 @@ 'hw_version': None, 'id': , 'identifiers': set({ + tuple( + 'togrill', + '00000000-0000-0000-0000-000000000001', + ), }), 'labels': set({ }), diff --git a/tests/components/togrill/snapshots/test_number.ambr b/tests/components/togrill/snapshots/test_number.ambr index 639f2758c69..8525cd783df 100644 --- a/tests/components/togrill/snapshots/test_number.ambr +++ b/tests/components/togrill/snapshots/test_number.ambr @@ -58,7 +58,7 @@ 'state': '0', }) # --- -# name: test_setup[no_data][number.pro_05_target_1-entry] +# name: test_setup[no_data][number.probe_1_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -76,7 +76,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.pro_05_target_1', + 'entity_id': 'number.probe_1_maximum_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -87,8 +87,128 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, - 'original_name': 'Target 1', + 'original_icon': 'mdi:thermometer-chevron-up', + 'original_name': 'Maximum temperature', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_maximum', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_maximum_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.probe_1_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Probe 1 Maximum temperature', + 'icon': 'mdi:thermometer-chevron-up', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.probe_1_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[no_data][number.probe_1_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer-chevron-down', + 'original_name': 'Minimum temperature', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_minimum', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_minimum_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.probe_1_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Probe 1 Minimum temperature', + 'icon': 'mdi:thermometer-chevron-down', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.probe_1_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[no_data][number.probe_1_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer-check', + 'original_name': 'Target temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -98,11 +218,12 @@ 'unit_of_measurement': , }) # --- -# name: test_setup[no_data][number.pro_05_target_1-state] +# name: test_setup[no_data][number.probe_1_target_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Target 1', + 'friendly_name': 'Probe 1 Target temperature', + 'icon': 'mdi:thermometer-check', 'max': 250, 'min': 0, 'mode': , @@ -110,14 +231,14 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.pro_05_target_1', + 'entity_id': 'number.probe_1_target_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup[no_data][number.pro_05_target_2-entry] +# name: test_setup[no_data][number.probe_2_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -135,7 +256,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.pro_05_target_2', + 'entity_id': 'number.probe_2_maximum_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -146,8 +267,128 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, - 'original_name': 'Target 2', + 'original_icon': 'mdi:thermometer-chevron-up', + 'original_name': 'Maximum temperature', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_maximum', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_maximum_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.probe_2_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Probe 2 Maximum temperature', + 'icon': 'mdi:thermometer-chevron-up', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.probe_2_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[no_data][number.probe_2_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer-chevron-down', + 'original_name': 'Minimum temperature', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_minimum', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_minimum_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.probe_2_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Probe 2 Minimum temperature', + 'icon': 'mdi:thermometer-chevron-down', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.probe_2_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[no_data][number.probe_2_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer-check', + 'original_name': 'Target temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -157,11 +398,12 @@ 'unit_of_measurement': , }) # --- -# name: test_setup[no_data][number.pro_05_target_2-state] +# name: test_setup[no_data][number.probe_2_target_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Target 2', + 'friendly_name': 'Probe 2 Target temperature', + 'icon': 'mdi:thermometer-check', 'max': 250, 'min': 0, 'mode': , @@ -169,7 +411,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.pro_05_target_2', + 'entity_id': 'number.probe_2_target_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -235,7 +477,7 @@ 'state': '5', }) # --- -# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_1-entry] +# name: test_setup[one_probe_with_target_alarm][number.probe_1_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -253,7 +495,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.pro_05_target_1', + 'entity_id': 'number.probe_1_maximum_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -264,8 +506,128 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, - 'original_name': 'Target 1', + 'original_icon': 'mdi:thermometer-chevron-up', + 'original_name': 'Maximum temperature', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_maximum', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_maximum_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_1_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Probe 1 Maximum temperature', + 'icon': 'mdi:thermometer-chevron-up', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.probe_1_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_1_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer-chevron-down', + 'original_name': 'Minimum temperature', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_minimum', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_minimum_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_1_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Probe 1 Minimum temperature', + 'icon': 'mdi:thermometer-chevron-down', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.probe_1_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_1_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer-check', + 'original_name': 'Target temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -275,11 +637,12 @@ 'unit_of_measurement': , }) # --- -# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_1-state] +# name: test_setup[one_probe_with_target_alarm][number.probe_1_target_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Target 1', + 'friendly_name': 'Probe 1 Target temperature', + 'icon': 'mdi:thermometer-check', 'max': 250, 'min': 0, 'mode': , @@ -287,14 +650,14 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.pro_05_target_1', + 'entity_id': 'number.probe_1_target_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '50.0', }) # --- -# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_2-entry] +# name: test_setup[one_probe_with_target_alarm][number.probe_2_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -312,7 +675,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.pro_05_target_2', + 'entity_id': 'number.probe_2_maximum_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -323,8 +686,128 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, - 'original_name': 'Target 2', + 'original_icon': 'mdi:thermometer-chevron-up', + 'original_name': 'Maximum temperature', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_maximum', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_maximum_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_2_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Probe 2 Maximum temperature', + 'icon': 'mdi:thermometer-chevron-up', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.probe_2_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_2_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer-chevron-down', + 'original_name': 'Minimum temperature', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_minimum', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_minimum_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_2_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Probe 2 Minimum temperature', + 'icon': 'mdi:thermometer-chevron-down', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.probe_2_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_2_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer-check', + 'original_name': 'Target temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, @@ -334,11 +817,12 @@ 'unit_of_measurement': , }) # --- -# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_2-state] +# name: test_setup[one_probe_with_target_alarm][number.probe_2_target_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Target 2', + 'friendly_name': 'Probe 2 Target temperature', + 'icon': 'mdi:thermometer-check', 'max': 250, 'min': 0, 'mode': , @@ -346,7 +830,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.pro_05_target_2', + 'entity_id': 'number.probe_2_target_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/togrill/snapshots/test_select.ambr b/tests/components/togrill/snapshots/test_select.ambr new file mode 100644 index 00000000000..7755b51d2f6 --- /dev/null +++ b/tests/components/togrill/snapshots/test_select.ambr @@ -0,0 +1,901 @@ +# serializer version: 1 +# name: test_setup[no_data][select.probe_1_grill_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_1_grill_type', + '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': 'Grill type', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grill_type', + 'unique_id': '00000000-0000-0000-0000-000000000001_grill_type_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[no_data][select.probe_1_grill_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 1 Grill type', + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'context': , + 'entity_id': 'select.probe_1_grill_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[no_data][select.probe_1_taste-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_1_taste', + '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': 'Taste', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taste', + 'unique_id': '00000000-0000-0000-0000-000000000001_taste_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[no_data][select.probe_1_taste-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 1 Taste', + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'context': , + 'entity_id': 'select.probe_1_taste', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[no_data][select.probe_2_grill_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_2_grill_type', + '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': 'Grill type', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grill_type', + 'unique_id': '00000000-0000-0000-0000-000000000001_grill_type_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[no_data][select.probe_2_grill_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 2 Grill type', + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'context': , + 'entity_id': 'select.probe_2_grill_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[no_data][select.probe_2_taste-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_2_taste', + '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': 'Taste', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taste', + 'unique_id': '00000000-0000-0000-0000-000000000001_taste_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[no_data][select.probe_2_taste-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 2 Taste', + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'context': , + 'entity_id': 'select.probe_2_taste', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[probes_with_different_data][select.probe_1_grill_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_1_grill_type', + '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': 'Grill type', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grill_type', + 'unique_id': '00000000-0000-0000-0000-000000000001_grill_type_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[probes_with_different_data][select.probe_1_grill_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 1 Grill type', + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'context': , + 'entity_id': 'select.probe_1_grill_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'beef', + }) +# --- +# name: test_setup[probes_with_different_data][select.probe_1_taste-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_1_taste', + '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': 'Taste', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taste', + 'unique_id': '00000000-0000-0000-0000-000000000001_taste_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[probes_with_different_data][select.probe_1_taste-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 1 Taste', + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'context': , + 'entity_id': 'select.probe_1_taste', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[probes_with_different_data][select.probe_2_grill_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_2_grill_type', + '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': 'Grill type', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grill_type', + 'unique_id': '00000000-0000-0000-0000-000000000001_grill_type_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[probes_with_different_data][select.probe_2_grill_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 2 Grill type', + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'context': , + 'entity_id': 'select.probe_2_grill_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[probes_with_different_data][select.probe_2_taste-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_2_taste', + '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': 'Taste', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taste', + 'unique_id': '00000000-0000-0000-0000-000000000001_taste_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[probes_with_different_data][select.probe_2_taste-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 2 Taste', + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'context': , + 'entity_id': 'select.probe_2_taste', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[probes_with_unknown_data][select.probe_1_grill_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_1_grill_type', + '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': 'Grill type', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grill_type', + 'unique_id': '00000000-0000-0000-0000-000000000001_grill_type_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[probes_with_unknown_data][select.probe_1_grill_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 1 Grill type', + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'context': , + 'entity_id': 'select.probe_1_grill_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[probes_with_unknown_data][select.probe_1_taste-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_1_taste', + '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': 'Taste', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taste', + 'unique_id': '00000000-0000-0000-0000-000000000001_taste_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[probes_with_unknown_data][select.probe_1_taste-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 1 Taste', + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'context': , + 'entity_id': 'select.probe_1_taste', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[probes_with_unknown_data][select.probe_2_grill_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_2_grill_type', + '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': 'Grill type', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grill_type', + 'unique_id': '00000000-0000-0000-0000-000000000001_grill_type_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[probes_with_unknown_data][select.probe_2_grill_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 2 Grill type', + 'options': list([ + 'none', + 'beef', + 'veal', + 'lamb', + 'pork', + 'turkey', + 'chicken', + 'sausage', + 'fish', + 'hamburger', + 'bbq_smoke', + 'hot_smoke', + 'cold_smoke', + 'mark_a', + 'mark_b', + 'mark_c', + ]), + }), + 'context': , + 'entity_id': 'select.probe_2_grill_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_setup[probes_with_unknown_data][select.probe_2_taste-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.probe_2_taste', + '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': 'Taste', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taste', + 'unique_id': '00000000-0000-0000-0000-000000000001_taste_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[probes_with_unknown_data][select.probe_2_taste-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Probe 2 Taste', + 'options': list([ + 'none', + 'rare', + 'medium_rare', + 'medium', + 'medium_well', + 'well_done', + ]), + }), + 'context': , + 'entity_id': 'select.probe_2_taste', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- diff --git a/tests/components/togrill/snapshots/test_sensor.ambr b/tests/components/togrill/snapshots/test_sensor.ambr index bc55d831500..d92dbca0a04 100644 --- a/tests/components/togrill/snapshots/test_sensor.ambr +++ b/tests/components/togrill/snapshots/test_sensor.ambr @@ -55,7 +55,7 @@ 'state': '45', }) # --- -# name: test_setup[battery][sensor.pro_05_probe_1-entry] +# name: test_setup[battery][sensor.probe_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -70,7 +70,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.pro_05_probe_1', + 'entity_id': 'sensor.probe_1_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -85,33 +85,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Probe 1', + 'original_name': 'Temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', 'unit_of_measurement': , }) # --- -# name: test_setup[battery][sensor.pro_05_probe_1-state] +# name: test_setup[battery][sensor.probe_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Probe 1', + 'friendly_name': 'Probe 1 Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.pro_05_probe_1', + 'entity_id': 'sensor.probe_1_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unavailable', }) # --- -# name: test_setup[battery][sensor.pro_05_probe_2-entry] +# name: test_setup[battery][sensor.probe_2_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -126,7 +126,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.pro_05_probe_2', + 'entity_id': 'sensor.probe_2_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -141,26 +141,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Probe 2', + 'original_name': 'Temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', 'unit_of_measurement': , }) # --- -# name: test_setup[battery][sensor.pro_05_probe_2-state] +# name: test_setup[battery][sensor.probe_2_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Probe 2', + 'friendly_name': 'Probe 2 Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.pro_05_probe_2', + 'entity_id': 'sensor.probe_2_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -223,7 +223,7 @@ 'state': '0', }) # --- -# name: test_setup[no_data][sensor.pro_05_probe_1-entry] +# name: test_setup[no_data][sensor.probe_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -238,7 +238,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.pro_05_probe_1', + 'entity_id': 'sensor.probe_1_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -253,33 +253,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Probe 1', + 'original_name': 'Temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', 'unit_of_measurement': , }) # --- -# name: test_setup[no_data][sensor.pro_05_probe_1-state] +# name: test_setup[no_data][sensor.probe_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Probe 1', + 'friendly_name': 'Probe 1 Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.pro_05_probe_1', + 'entity_id': 'sensor.probe_1_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unavailable', }) # --- -# name: test_setup[no_data][sensor.pro_05_probe_2-entry] +# name: test_setup[no_data][sensor.probe_2_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -294,7 +294,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.pro_05_probe_2', + 'entity_id': 'sensor.probe_2_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -309,26 +309,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Probe 2', + 'original_name': 'Temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', 'unit_of_measurement': , }) # --- -# name: test_setup[no_data][sensor.pro_05_probe_2-state] +# name: test_setup[no_data][sensor.probe_2_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Probe 2', + 'friendly_name': 'Probe 2 Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.pro_05_probe_2', + 'entity_id': 'sensor.probe_2_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -391,7 +391,7 @@ 'state': '0', }) # --- -# name: test_setup[temp_data][sensor.pro_05_probe_1-entry] +# name: test_setup[temp_data][sensor.probe_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -406,7 +406,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.pro_05_probe_1', + 'entity_id': 'sensor.probe_1_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -421,33 +421,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Probe 1', + 'original_name': 'Temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', 'unit_of_measurement': , }) # --- -# name: test_setup[temp_data][sensor.pro_05_probe_1-state] +# name: test_setup[temp_data][sensor.probe_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Probe 1', + 'friendly_name': 'Probe 1 Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.pro_05_probe_1', + 'entity_id': 'sensor.probe_1_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '10', }) # --- -# name: test_setup[temp_data][sensor.pro_05_probe_2-entry] +# name: test_setup[temp_data][sensor.probe_2_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -462,7 +462,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.pro_05_probe_2', + 'entity_id': 'sensor.probe_2_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -477,26 +477,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Probe 2', + 'original_name': 'Temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', 'unit_of_measurement': , }) # --- -# name: test_setup[temp_data][sensor.pro_05_probe_2-state] +# name: test_setup[temp_data][sensor.probe_2_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Probe 2', + 'friendly_name': 'Probe 2 Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.pro_05_probe_2', + 'entity_id': 'sensor.probe_2_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -559,7 +559,7 @@ 'state': '0', }) # --- -# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-entry] +# name: test_setup[temp_data_missing_probe][sensor.probe_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -574,7 +574,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.pro_05_probe_1', + 'entity_id': 'sensor.probe_1_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -589,33 +589,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Probe 1', + 'original_name': 'Temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', 'unit_of_measurement': , }) # --- -# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-state] +# name: test_setup[temp_data_missing_probe][sensor.probe_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Probe 1', + 'friendly_name': 'Probe 1 Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.pro_05_probe_1', + 'entity_id': 'sensor.probe_1_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '10', }) # --- -# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-entry] +# name: test_setup[temp_data_missing_probe][sensor.probe_2_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -630,7 +630,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.pro_05_probe_2', + 'entity_id': 'sensor.probe_2_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -645,26 +645,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Probe 2', + 'original_name': 'Temperature', 'platform': 'togrill', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', 'unit_of_measurement': , }) # --- -# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-state] +# name: test_setup[temp_data_missing_probe][sensor.probe_2_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Pro-05 Probe 2', + 'friendly_name': 'Probe 2 Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.pro_05_probe_2', + 'entity_id': 'sensor.probe_2_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/togrill/test_event.py b/tests/components/togrill/test_event.py index 6aa6019303a..a42febbe2ce 100644 --- a/tests/components/togrill/test_event.py +++ b/tests/components/togrill/test_event.py @@ -68,11 +68,11 @@ async def test_events( mock_client.mocked_notify(PacketA5Notify(probe=1, message=message)) - state = hass.states.get("event.pro_05_probe_2") + state = hass.states.get("event.probe_2_event") assert state assert state.state == STATE_UNKNOWN - state = hass.states.get("event.pro_05_probe_1") + state = hass.states.get("event.probe_1_event") assert state assert state.state == "2023-10-21T00:00:00.000+00:00" assert state.attributes.get(ATTR_EVENT_TYPE) == slugify(message.name) diff --git a/tests/components/togrill/test_number.py b/tests/components/togrill/test_number.py index f6031e114d1..fb88a0d466a 100644 --- a/tests/components/togrill/test_number.py +++ b/tests/components/togrill/test_number.py @@ -10,6 +10,7 @@ from togrill_bluetooth.packets import ( PacketA0Notify, PacketA6Write, PacketA8Notify, + PacketA300Write, PacketA301Write, ) @@ -87,7 +88,7 @@ async def test_setup( temperature_1=50.0, ), ], - "number.pro_05_target_1", + "number.probe_1_target_temperature", 100.0, PacketA301Write(probe=1, target=100), id="probe", @@ -100,11 +101,67 @@ async def test_setup( temperature_1=50.0, ), ], - "number.pro_05_target_1", + "number.probe_1_target_temperature", 0.0, PacketA301Write(probe=1, target=None), id="probe_clear", ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_RANGE, + temperature_1=50.0, + temperature_2=80.0, + ), + ], + "number.probe_1_minimum_temperature", + 100.0, + PacketA300Write(probe=1, minimum=100.0, maximum=80.0), + id="minimum", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_RANGE, + temperature_1=None, + temperature_2=80.0, + ), + ], + "number.probe_1_minimum_temperature", + 0.0, + PacketA300Write(probe=1, minimum=None, maximum=80.0), + id="minimum_clear", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_RANGE, + temperature_1=50.0, + temperature_2=80.0, + ), + ], + "number.probe_1_maximum_temperature", + 100.0, + PacketA300Write(probe=1, minimum=50.0, maximum=100.0), + id="maximum", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_RANGE, + temperature_1=50.0, + temperature_2=None, + ), + ], + "number.probe_1_maximum_temperature", + 0.0, + PacketA300Write(probe=1, minimum=50.0, maximum=None), + id="maximum_clear", + ), pytest.param( [ PacketA0Notify( @@ -203,7 +260,7 @@ async def test_set_number_write_error( ATTR_VALUE: 100, }, target={ - ATTR_ENTITY_ID: "number.pro_05_target_1", + ATTR_ENTITY_ID: "number.probe_1_target_temperature", }, blocking=True, ) @@ -237,7 +294,7 @@ async def test_set_number_disconnected( ATTR_VALUE: 100, }, target={ - ATTR_ENTITY_ID: "number.pro_05_target_1", + ATTR_ENTITY_ID: "number.probe_1_target_temperature", }, blocking=True, ) diff --git a/tests/components/togrill/test_select.py b/tests/components/togrill/test_select.py new file mode 100644 index 00000000000..0a9e858966d --- /dev/null +++ b/tests/components/togrill/test_select.py @@ -0,0 +1,172 @@ +"""Test select for ToGrill integration.""" + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.packets import ( + GrillType, + PacketA0Notify, + PacketA8Notify, + PacketA303Write, + Taste, +) + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + "packets", + [ + pytest.param([], id="no_data"), + pytest.param( + [ + PacketA0Notify( + battery=45, + version_major=1, + version_minor=5, + function_type=1, + probe_count=2, + ambient=False, + alarm_interval=5, + alarm_sound=True, + ), + PacketA8Notify( + probe=1, + alarm_type=0, + grill_type=1, + ), + PacketA8Notify( + probe=2, + alarm_type=0, + taste=1, + ), + PacketA8Notify(probe=2, alarm_type=None), + ], + id="probes_with_different_data", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=0, + grill_type=99, + ), + PacketA8Notify( + probe=2, + alarm_type=0, + taste=99, + ), + ], + id="probes_with_unknown_data", + ), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test the setup.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SELECT]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +@pytest.mark.parametrize( + ("packets", "entity_id", "value", "write_packet"), + [ + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "select.probe_1_grill_type", + "veal", + PacketA303Write(probe=1, grill_type=GrillType.VEAL, taste=None), + id="grill_type", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + grill_type=GrillType.BEEF, + ), + ], + "select.probe_1_taste", + "medium", + PacketA303Write(probe=1, grill_type=GrillType.BEEF, taste=Taste.MEDIUM), + id="taste", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + grill_type=GrillType.BEEF, + taste=Taste.MEDIUM, + ), + ], + "select.probe_1_taste", + "none", + PacketA303Write(probe=1, grill_type=GrillType.BEEF, taste=None), + id="taste_none", + ), + ], +) +async def test_set_option( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, + entity_id, + value, + write_packet, +) -> None: + """Test the selection of option.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SELECT]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + service_data={ + ATTR_OPTION: value, + }, + target={ + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + mock_client.write.assert_any_call(write_packet) diff --git a/tests/components/togrill/test_sensor.py b/tests/components/togrill/test_sensor.py index d7662d483af..913a295d379 100644 --- a/tests/components/togrill/test_sensor.py +++ b/tests/components/togrill/test_sensor.py @@ -1,7 +1,8 @@ """Test sensors for ToGrill integration.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch +from habluetooth import BluetoothServiceInfoBleak import pytest from syrupy.assertion import SnapshotAssertion from togrill_bluetooth.packets import PacketA0Notify, PacketA1Notify @@ -16,6 +17,16 @@ from tests.common import MockConfigEntry, snapshot_platform from tests.components.bluetooth import inject_bluetooth_service_info +def patch_async_ble_device_from_address( + return_value: BluetoothServiceInfoBleak | None = None, +): + """Patch async_ble_device_from_address to return a mocked BluetoothServiceInfoBleak.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + @pytest.mark.parametrize( "packets", [ @@ -57,3 +68,51 @@ async def test_setup( mock_client.mocked_notify(packet) await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +async def test_device_disconnected( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the switch set.""" + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + entity_id = "sensor.pro_05_battery" + + state = hass.states.get(entity_id) + assert state + assert state.state == "0" + + with patch_async_ble_device_from_address(): + mock_client.mocked_disconnected_callback() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + +async def test_device_discovered( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the switch set.""" + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + entity_id = "sensor.pro_05_battery" + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "0" diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index e918edf70a4..b6cb8f91f82 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from tests.common import MockConfigEntry + MOCK_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="mock_hostname" ) @@ -36,6 +38,22 @@ def coordinator_toloclient() -> Mock: yield toloclient +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="TOLO Steam Bath", + entry_id="1", + data={ + CONF_HOST: "127.0.0.1", + }, + ) + config_entry.add_to_hass(hass) + + return config_entry + + async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) -> None: """Test a user initiated config flow with provided host which times out.""" toloclient().get_status.side_effect = ToloCommunicationError @@ -64,25 +82,25 @@ async def test_user_walkthrough( toloclient().get_status.side_effect = lambda *args, **kwargs: None - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "127.0.0.2"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} toloclient().get_status.side_effect = lambda *args, **kwargs: object() - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "127.0.0.1"}, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "TOLO Sauna" - assert result3["data"][CONF_HOST] == "127.0.0.1" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "TOLO Sauna" + assert result["data"][CONF_HOST] == "127.0.0.1" async def test_dhcp( @@ -116,3 +134,77 @@ async def test_dhcp_invalid_device(hass: HomeAssistant, toloclient: Mock) -> Non DOMAIN, context={"source": SOURCE_DHCP}, data=MOCK_DHCP_DATA ) assert result["type"] is FlowResultType.ABORT + + +async def test_reconfigure_walkthrough( + hass: HomeAssistant, + toloclient: Mock, + coordinator_toloclient: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test a reconfigure flow without problems.""" + result = await config_entry.start_reconfigure_flow(hass) + + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.4"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.4" + + +async def test_reconfigure_error_then_fix( + hass: HomeAssistant, + toloclient: Mock, + coordinator_toloclient: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test a reconfigure flow which first fails and then recovers.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "user" + + toloclient().get_status.side_effect = ToloCommunicationError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.5"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + toloclient().get_status.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.4"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.4" + + +async def test_reconfigure_duplicate_ip( + hass: HomeAssistant, + toloclient: Mock, + coordinator_toloclient: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test a reconfigure flow where the user is trying to have to entries with the same IP.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.6"}, unique_id="second_entry" + ) + config_entry2.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.6"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert config_entry.data[CONF_HOST] == "127.0.0.1" diff --git a/tests/components/tractive/test_switch.py b/tests/components/tractive/test_switch.py index 92e4676aef1..0b9213bee92 100644 --- a/tests/components/tractive/test_switch.py +++ b/tests/components/tractive/test_switch.py @@ -12,6 +12,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -226,3 +227,42 @@ async def test_switch_off_with_exception( state = hass.states.get(entity_id) assert state assert state.state == STATE_ON + + +async def test_switch_unavailable( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch is navailable when the tracker is in the energy saving zone.""" + entity_id = "switch.test_pet_tracker_buzzer" + + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + event = { + "tracker_id": "device_id_123", + "buzzer_control": {"active": True}, + "led_control": {"active": False}, + "live_tracking": {"active": True}, + "tracker_state_reason": "POWER_SAVING", + } + mock_tractive_client.send_switch_event(mock_config_entry, event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 74cea380351..6d567a22c02 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -285,6 +285,7 @@ class MockResultStream(ResultStream): supports_streaming_input=True, language="en", options={}, + hass=hass, _manager=hass.data[DATA_TTS_MANAGER], ) hass.data[DATA_TTS_MANAGER].token_to_stream[self.token] = self diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index be155aae182..dc50f18d5e1 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2,9 +2,12 @@ import asyncio from http import HTTPStatus +import io from pathlib import Path +import tempfile from typing import Any from unittest.mock import MagicMock, Mock, patch +import wave from freezegun.api import FrozenDateTimeFactory import pytest @@ -2063,3 +2066,74 @@ async def test_async_internal_get_tts_audio_called( # async_internal_get_tts_audio is called internal_get_tts_audio.assert_called_once_with("test message", "en_US", {}) + + +async def test_stream_override( + hass: HomeAssistant, mock_tts_entity: MockTTSEntity +) -> None: + """Test overriding streams with a media path.""" + await mock_config_entry_setup(hass, mock_tts_entity) + + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) + stream.async_set_message("beer") + + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as wav_file: + with wave.open(wav_file, "wb") as wav_writer: + wav_writer.setframerate(16000) + wav_writer.setsampwidth(2) + wav_writer.setnchannels(1) + wav_writer.writeframes(bytes(16000 * 2)) # 1 second @ 16Khz/mono + + wav_file.seek(0) + + stream.async_override_result(wav_file.name) + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) + + # Verify the result + with io.BytesIO(result_data) as wav_io, wave.open(wav_io, "rb") as wav_reader: + assert wav_reader.getframerate() == 16000 + assert wav_reader.getsampwidth() == 2 + assert wav_reader.getnchannels() == 1 + assert wav_reader.readframes(wav_reader.getnframes()) == bytes( + 16000 * 2 + ) # 1 second @ 16Khz/mono + + +async def test_stream_override_with_conversion( + hass: HomeAssistant, mock_tts_entity: MockTTSEntity +) -> None: + """Test overriding streams with a media path that requires conversion.""" + await mock_config_entry_setup(hass, mock_tts_entity) + + stream = tts.async_create_stream( + hass, + mock_tts_entity.entity_id, + options={ + tts.ATTR_PREFERRED_FORMAT: "wav", + tts.ATTR_PREFERRED_SAMPLE_RATE: 22050, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, + }, + ) + stream.async_set_message("beer") + + # Use a temp file here since ffmpeg will read it directly + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as wav_file: + with wave.open(wav_file, "wb") as wav_writer: + wav_writer.setframerate(16000) + wav_writer.setsampwidth(2) + wav_writer.setnchannels(1) + wav_writer.writeframes(bytes(16000 * 2)) # 1 second @ 16Khz/mono + + wav_file.seek(0) + stream.async_override_result(wav_file.name) + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) + + # Verify the result has the preferred format + with io.BytesIO(result_data) as wav_io, wave.open(wav_io, "rb") as wav_reader: + assert wav_reader.getframerate() == 22050 + assert wav_reader.getsampwidth() == 2 + assert wav_reader.getnchannels() == 2 + assert wav_reader.readframes(wav_reader.getnframes()) == bytes( + 22050 * 2 * 2 + ) # 1 second @ 22.5Khz/stereo diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1fdc28bcb9f..13c24046d2f 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -5,9 +5,9 @@ from __future__ import annotations from typing import Any from unittest.mock import patch -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import DeviceListener, ManagerCompat +from homeassistant.components.tuya import DeviceListener from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -15,38 +15,52 @@ from tests.common import MockConfigEntry DEVICE_MOCKS = [ "bzyd_45idzfufidgee7ir", # https://github.com/orgs/home-assistant/discussions/717 "bzyd_ssimhf6r8kgwepfb", # https://github.com/orgs/home-assistant/discussions/718 + "cjkg_uenof8jd", # https://github.com/home-assistant/core/issues/151825 "ckmkzq_1yyqfw4djv9eii3q", # https://github.com/home-assistant/core/issues/150856 "cl_3r8gc33pnqsxfe1g", # https://github.com/tuya/tuya-home-assistant/issues/754 "cl_669wsr2w4cvinbh4", # https://github.com/home-assistant/core/issues/150856 "cl_cpbo62rn", # https://github.com/orgs/home-assistant/discussions/539 "cl_ebt12ypvexnixvtf", # https://github.com/tuya/tuya-home-assistant/issues/754 "cl_g1cp07dsqnbdbbki", # https://github.com/home-assistant/core/issues/139966 + "cl_lfkr93x0ukp5gaia", # https://github.com/home-assistant/core/issues/152826 "cl_qqdxfdht", # https://github.com/orgs/home-assistant/discussions/539 + "cl_rD7uqAAgQOpSA2Rx", # https://github.com/home-assistant/core/issues/139966 "cl_zah67ekd", # https://github.com/home-assistant/core/issues/71242 "clkg_nhyj64w2", # https://github.com/home-assistant/core/issues/136055 - "co2bj_yrr3eiyiacm31ski", # https://github.com/home-assistant/core/issues/133173 + "clkg_wltqkykhni0papzj", # https://github.com/home-assistant/core/issues/151635 + "clkg_xqvhthwkbmp3aghs", # https://github.com/home-assistant/core/issues/139966 + "co2bj_yakol79dibtswovc", # https://github.com/home-assistant/core/issues/151784 + "co2bj_yrr3eiyiacm31ski", # https://github.com/orgs/home-assistant/discussions/842 "cobj_hcdy5zrq3ikzthws", # https://github.com/orgs/home-assistant/discussions/482 + "cs_b9oyi2yofflroq1g", # https://github.com/home-assistant/core/issues/139966 + "cs_eguoms25tkxtf5u8", # https://github.com/home-assistant/core/issues/152361 "cs_ipmyy4nigpqcnd8q", # https://github.com/home-assistant/core/pull/148726 "cs_ka2wfrdoogpvgzfi", # https://github.com/home-assistant/core/issues/119865 "cs_qhxmvae667uap4zh", # https://github.com/home-assistant/core/issues/141278 "cs_vmxuxszzjwp5smli", # https://github.com/home-assistant/core/issues/119865 "cs_zibqa9dutqyaxym2", # https://github.com/home-assistant/core/pull/125098 "cwjwq_agwu93lr", # https://github.com/orgs/home-assistant/discussions/79 + "cwwsq_lxfvx41gqdotrkgi", # https://github.com/orgs/home-assistant/discussions/730 "cwwsq_wfkzyy0evslzsmoi", # https://github.com/home-assistant/core/issues/144745 "cwysj_akln8rb04cav403q", # https://github.com/home-assistant/core/pull/146599 "cwysj_z3rpyvznfcch99aa", # https://github.com/home-assistant/core/pull/146599 + "cz_0fHWRe8ULjtmnBNd", # https://github.com/home-assistant/core/issues/139966 "cz_0g1fmqh6d5io7lcn", # https://github.com/home-assistant/core/issues/149704 "cz_2iepauebcvo74ujc", # https://github.com/home-assistant/core/issues/141278 "cz_2jxesipczks0kdct", # https://github.com/home-assistant/core/issues/147149 "cz_37mnhia3pojleqfh", # https://github.com/home-assistant/core/issues/146164 "cz_39sy2g68gsjwo2xv", # https://github.com/home-assistant/core/issues/141278 "cz_6fa7odsufen374x2", # https://github.com/home-assistant/core/issues/150029 + "cz_79a7z01v3n35kytb", # https://github.com/orgs/home-assistant/discussions/221 "cz_9ivirni8wemum6cw", # https://github.com/home-assistant/core/issues/139735 "cz_AiHXxAyyn7eAkLQY", # https://github.com/home-assistant/core/issues/150662 "cz_CHLZe9HQ6QIXujVN", # https://github.com/home-assistant/core/issues/149233 "cz_HBRBzv1UVBVfF6SL", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_IGzCi97RpN2Lf9cu", # https://github.com/home-assistant/core/issues/139966 + "cz_PGEkBctAbtzKOZng", # https://github.com/home-assistant/core/issues/139966 "cz_anwgf2xugjxpkfxb", # https://github.com/orgs/home-assistant/discussions/539 "cz_cuhokdii7ojyw8k2", # https://github.com/home-assistant/core/issues/149704 + "cz_dhto3y4uachr1wll", # https://github.com/orgs/home-assistant/discussions/169 "cz_dntgh2ngvshfxpsz", # https://github.com/home-assistant/core/issues/149704 "cz_fencxse0bnut96ig", # https://github.com/home-assistant/core/issues/63978 "cz_gbtxrqfy9xcsakyp", # https://github.com/home-assistant/core/issues/141278 @@ -57,15 +71,19 @@ DEVICE_MOCKS = [ "cz_ipabufmlmodje1ws", # https://github.com/home-assistant/core/issues/63978 "cz_iqhidxhhmgxk5eja", # https://github.com/home-assistant/core/issues/149233 "cz_jnbbxsb84gvvyfg5", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_mQUhiTg9kwydBFBd", # https://github.com/home-assistant/core/issues/139966 "cz_n8iVBAPLFKAAAszH", # https://github.com/home-assistant/core/issues/146164 "cz_nkb0fmtlfyqosnvk", # https://github.com/orgs/home-assistant/discussions/482 "cz_nx8rv6jpe1tsnffk", # https://github.com/home-assistant/core/issues/148347 + "cz_piuensvr", # https://github.com/home-assistant/core/issues/139966 "cz_qm0iq4nqnrlzh4qc", # https://github.com/home-assistant/core/issues/141278 + "cz_qxJSyTLEtX5WrzA9", # https://github.com/home-assistant/core/issues/139966 "cz_raceucn29wk2yawe", # https://github.com/tuya/tuya-home-assistant/issues/754 "cz_sb6bwb1n8ma2c5q4", # https://github.com/home-assistant/core/issues/141278 "cz_t0a4hwsf8anfsadp", # https://github.com/home-assistant/core/issues/149704 "cz_tf6qp8t3hl9h7m94", # https://github.com/home-assistant/core/issues/143209 "cz_tkn2s79mzedk6pwr", # https://github.com/home-assistant/core/issues/146164 + "cz_vrbpx6h7fsi5mujb", # https://github.com/home-assistant/core/pull/149234 "cz_vxqn72kwtosoy4d3", # https://github.com/home-assistant/core/issues/141278 "cz_w0qqde0g", # https://github.com/orgs/home-assistant/discussions/482 "cz_wifvoilfrqeo6hvu", # https://github.com/home-assistant/core/issues/146164 @@ -115,6 +133,7 @@ DEVICE_MOCKS = [ "dj_zputiamzanuk6yky", # https://github.com/home-assistant/core/issues/149704 "dlq_0tnvg2xaisqdadcf", # https://github.com/home-assistant/core/issues/102769 "dlq_cnpkf4xdmd9v49iq", # https://github.com/home-assistant/core/pull/149320 + "dlq_dikb3dp6", # https://github.com/home-assistant/core/pull/151601 "dlq_jdj6ccklup7btq3a", # https://github.com/home-assistant/core/issues/143209 "dlq_kxdr6su0c55p7bbo", # https://github.com/home-assistant/core/issues/143499 "dlq_r9kg2g1uhhyicycb", # https://github.com/home-assistant/core/issues/149650 @@ -127,6 +146,7 @@ DEVICE_MOCKS = [ "hps_2aaelwxk", # https://github.com/home-assistant/core/issues/149704 "hps_wqashyqo", # https://github.com/home-assistant/core/issues/146180 "hwsb_ircs2n82vgrozoew", # https://github.com/home-assistant/core/issues/149233 + "jsq_r492ifwk6f2ssptb", # https://github.com/home-assistant/core/issues/151488 "jtmspro_xqeob8h6", # https://github.com/orgs/home-assistant/discussions/517 "kg_4nqs33emdwJxpQ8O", # https://github.com/orgs/home-assistant/discussions/539 "kg_5ftkaulg", # https://github.com/orgs/home-assistant/discussions/539 @@ -146,7 +166,9 @@ DEVICE_MOCKS = [ "mcs_7jIGJAymiH8OsFFb", # https://github.com/home-assistant/core/issues/108301 "mcs_8yhypbo7", # https://github.com/orgs/home-assistant/discussions/482 "mcs_hx5ztlztij4yxxvg", # https://github.com/home-assistant/core/issues/148347 + "mcs_oxslv1c9", # https://github.com/home-assistant/core/issues/139966 "mcs_qxu3flpqjsc1kqu3", # https://github.com/home-assistant/core/issues/141278 + "msp_3ddulzljdjjwkhoy", # https://github.com/orgs/home-assistant/discussions/262 "mzj_jlapoy5liocmtdvd", # https://github.com/home-assistant/core/issues/150662 "mzj_qavcakohisj5adyh", # https://github.com/home-assistant/core/issues/141278 "ntq_9mqdhwklpvnnvb7t", # https://github.com/orgs/home-assistant/discussions/517 @@ -156,12 +178,16 @@ DEVICE_MOCKS = [ "pc_yku9wsimasckdt15", # https://github.com/orgs/home-assistant/discussions/482 "pir_3amxzozho9xp4mkh", # https://github.com/home-assistant/core/issues/149704 "pir_fcdjzz3s", # https://github.com/home-assistant/core/issues/149704 + "pir_j5jgnjvdaczeb6dc", # https://github.com/orgs/home-assistant/discussions/582 "pir_wqz93nrdomectyoz", # https://github.com/home-assistant/core/issues/149704 "qccdz_7bvgooyjhiua1yyq", # https://github.com/home-assistant/core/issues/136207 "qn_5ls2jw49hpczwqng", # https://github.com/home-assistant/core/issues/149233 + "qt_TtXKwTMwiPpURWLJ", # https://github.com/home-assistant/core/issues/139966 "qxj_fsea1lat3vuktbt6", # https://github.com/orgs/home-assistant/discussions/318 "qxj_is2indt9nlth6esa", # https://github.com/home-assistant/core/issues/136472 + "qxj_xbwbniyt6bgws9ia", # https://github.com/orgs/home-assistant/discussions/823 "rqbj_4iqe2hsfyd86kwwc", # https://github.com/orgs/home-assistant/discussions/100 + "rs_d7woucobqi8ncacf", # https://github.com/orgs/home-assistant/discussions/1021 "sd_i6hyjg3af7doaswm", # https://github.com/orgs/home-assistant/discussions/539 "sd_lr33znaodtyarrrz", # https://github.com/home-assistant/core/issues/141278 "sfkzq_1fcnd8xk", # https://github.com/orgs/home-assistant/discussions/539 @@ -170,24 +196,32 @@ DEVICE_MOCKS = [ "sfkzq_nxquc5lb", # https://github.com/home-assistant/core/issues/150662 "sfkzq_o6dagifntoafakst", # https://github.com/home-assistant/core/issues/148116 "sfkzq_rzklytdei8i8vo37", # https://github.com/home-assistant/core/issues/146164 + "sgbj_DYgId0sz6zWlmmYu", # https://github.com/orgs/home-assistant/discussions/583 "sgbj_im2eqqhj72suwwko", # https://github.com/home-assistant/core/issues/151082 "sgbj_ulv4nnue7gqp0rjk", # https://github.com/home-assistant/core/issues/149704 "sj_rzeSU2h9uoklxEwq", # https://github.com/home-assistant/core/issues/150683 "sj_tgvtvdoc", # https://github.com/orgs/home-assistant/discussions/482 + "sjz_ftbc8rp8ipksdfpv", # https://github.com/orgs/home-assistant/discussions/51 + "sp_6bmk1remyscwyx6i", # https://github.com/orgs/home-assistant/discussions/842 "sp_drezasavompxpcgm", # https://github.com/home-assistant/core/issues/149704 "sp_nzauwyj3mcnjnf35", # https://github.com/home-assistant/core/issues/141278 "sp_rjKXWRohlvOTyLBu", # https://github.com/home-assistant/core/issues/149704 "sp_rudejjigkywujjvs", # https://github.com/home-assistant/core/issues/146164 "sp_sdd5f5f2dl5wydjf", # https://github.com/home-assistant/core/issues/144087 + "swtz_3rzngbyy", # https://github.com/orgs/home-assistant/discussions/688 + "szjcy_u5xgcpcngk3pfxb4", # https://github.com/orgs/home-assistant/discussions/934 "tdq_1aegphq4yfd50e6b", # https://github.com/home-assistant/core/issues/143209 "tdq_9htyiowaf5rtdhrv", # https://github.com/home-assistant/core/issues/143209 "tdq_cq1p0nt0a4rixnex", # https://github.com/home-assistant/core/issues/146845 "tdq_nockvv2k39vbrxxk", # https://github.com/home-assistant/core/issues/145849 + "tdq_p6sqiuesvhmhvv4f", # https://github.com/orgs/home-assistant/discussions/430 "tdq_pu8uhxhwcp3tgoz7", # https://github.com/home-assistant/core/issues/141278 "tdq_uoa3mayicscacseb", # https://github.com/home-assistant/core/issues/128911 + "tdq_x3o8epevyeo3z3oa", # https://github.com/orgs/home-assistant/discussions/430 "tyndj_pyakuuoc", # https://github.com/home-assistant/core/issues/149704 "wfcon_b25mh8sxawsgndck", # https://github.com/home-assistant/core/issues/149704 "wfcon_lieerjyy6l4ykjor", # https://github.com/home-assistant/core/issues/136055 + "wfcon_plp0gnfcacdeqk5o", # https://github.com/home-assistant/core/issues/139966 "wg2_2gowdgni", # https://github.com/home-assistant/core/issues/150856 "wg2_haclbl0qkqlf2qds", # https://github.com/orgs/home-assistant/discussions/517 "wg2_nwxr8qcu4seltoro", # https://github.com/orgs/home-assistant/discussions/430 @@ -195,13 +229,19 @@ DEVICE_MOCKS = [ "wg2_tmwhss6ntjfc7prs", # https://github.com/home-assistant/core/issues/150662 "wg2_v7owd9tzcaninc36", # https://github.com/orgs/home-assistant/discussions/539 "wk_6kijc7nd", # https://github.com/home-assistant/core/issues/136513 + "wk_IAYz2WK1th0cMLmL", # https://github.com/orgs/home-assistant/discussions/842 "wk_aqoouq7x", # https://github.com/home-assistant/core/issues/146263 "wk_ccpwojhalfxryigz", # https://github.com/home-assistant/core/issues/145551 + "wk_cpmgn2cf", # https://github.com/orgs/home-assistant/discussions/684 "wk_fi6dne5tu4t1nm6j", # https://github.com/orgs/home-assistant/discussions/243 "wk_gc1bxoq2hafxpa35", # https://github.com/home-assistant/core/issues/145551 "wk_gogb05wrtredz3bs", # https://github.com/home-assistant/core/issues/136337 + "wk_tfbhw0mg", # https://github.com/home-assistant/core/issues/152282 "wk_y5obtqhuztqsf2mj", # https://github.com/home-assistant/core/issues/139735 "wkcz_gc4b1mdw7kebtuyz", # https://github.com/home-assistant/core/issues/135617 + "wkf_9xfjixap", # https://github.com/home-assistant/core/issues/139966 + "wkf_p3dbf6qs", # https://github.com/home-assistant/core/issues/139966 + "wnykq_kzwdw5bpxlbs9h9g", # https://github.com/orgs/home-assistant/discussions/842 "wnykq_npbbca46yiug8ysk", # https://github.com/orgs/home-assistant/discussions/539 "wnykq_om518smspsaltzdi", # https://github.com/home-assistant/core/issues/150662 "wnykq_rqhxdyusjrwxyff6", # https://github.com/home-assistant/core/issues/133173 @@ -210,11 +250,15 @@ DEVICE_MOCKS = [ "wsdcg_iv7hudlj", # https://github.com/home-assistant/core/issues/141278 "wsdcg_krlcihrpzpc8olw9", # https://github.com/orgs/home-assistant/discussions/517 "wsdcg_lf36y5nwb8jkxwgg", # https://github.com/orgs/home-assistant/discussions/539 + "wsdcg_qrztc3ev", # https://github.com/home-assistant/core/issues/139966 "wsdcg_vtA4pDd6PLUZzXgZ", # https://github.com/orgs/home-assistant/discussions/482 "wsdcg_xr3htd96", # https://github.com/orgs/home-assistant/discussions/482 "wsdcg_yqiqbaldtr0i7mru", # https://github.com/home-assistant/core/issues/136223 "wxkg_ja5osu5g", # https://github.com/orgs/home-assistant/discussions/482 "wxkg_l8yaz4um5b3pwyvf", # https://github.com/home-assistant/core/issues/93975 + "wxnbq_5l1ht8jygsyr1wn1", # https://github.com/orgs/home-assistant/discussions/685 + "xdd_shx9mmadyyeaq88t", # https://github.com/home-assistant/core/issues/151141 + "xnyjcn_pb0tc75khaik8qbg", # https://github.com/home-assistant/core/pull/149237 "ydkt_jevroj5aguwdbs2e", # https://github.com/orgs/home-assistant/discussions/288 "ygsb_l6ax0u6jwbz82atk", # https://github.com/home-assistant/core/issues/146319 "ykq_bngwdjsr", # https://github.com/orgs/home-assistant/discussions/482 @@ -229,8 +273,10 @@ DEVICE_MOCKS = [ "zndb_4ggkyflayu1h1ho9", # https://github.com/home-assistant/core/pull/149317 "zndb_v5jlnn5hwyffkhp3", # https://github.com/home-assistant/core/issues/143209 "zndb_ze8faryrxr0glqnn", # https://github.com/home-assistant/core/issues/138372 + "znnbq_0kllybtbzftaee7y", # https://github.com/orgs/home-assistant/discussions/685 "znnbq_6b3pbbuqbfabhfiq", # https://github.com/orgs/home-assistant/discussions/707 "znrb_db81ge24jctwx8lo", # https://github.com/home-assistant/core/issues/136513 + "zwjcy_gvygg3m8", # https://github.com/orgs/home-assistant/discussions/949 "zwjcy_myd45weu", # https://github.com/orgs/home-assistant/discussions/482 ] @@ -260,7 +306,7 @@ class MockDeviceListener(DeviceListener): async def initialize_entry( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: CustomerDevice | list[CustomerDevice], ) -> None: @@ -273,8 +319,6 @@ async def initialize_entry( mock_config_entry.add_to_hass(hass) # Initialize the component - with patch( - "homeassistant.components.tuya.ManagerCompat", return_value=mock_manager - ): + with patch("homeassistant.components.tuya.Manager", return_value=mock_manager): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 08ede9b73d9..21e558b7192 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -6,9 +6,14 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest -from tuya_sharing import CustomerApi, CustomerDevice, DeviceFunction, DeviceStatusRange +from tuya_sharing import ( + CustomerApi, + CustomerDevice, + DeviceFunction, + DeviceStatusRange, + Manager, +) -from homeassistant.components.tuya import ManagerCompat from homeassistant.components.tuya.const import ( CONF_APP_TYPE, CONF_ENDPOINT, @@ -56,7 +61,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def mock_loaded_entry( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> MockConfigEntry: @@ -69,7 +74,7 @@ async def mock_loaded_entry( # Initialize the component with ( - patch("homeassistant.components.tuya.ManagerCompat", return_value=mock_manager), + patch("homeassistant.components.tuya.Manager", return_value=mock_manager), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -114,9 +119,9 @@ def mock_tuya_login_control() -> Generator[MagicMock]: @pytest.fixture -def mock_manager() -> ManagerCompat: +def mock_manager() -> Manager: """Mock Tuya Manager.""" - manager = MagicMock(spec=ManagerCompat) + manager = MagicMock(spec=Manager) manager.device_map = {} manager.mq = MagicMock() manager.mq.client = MagicMock() @@ -203,15 +208,17 @@ async def _create_device(hass: HomeAssistant, mock_device_code: str) -> Customer } device.status = details["status"] for key, value in device.status.items(): - if device.status_range[key].type == "Json": + # Some devices do not provide a status_range for all status DPs + # Others set the type as String in status_range and as Json in function + if ((dp_type := device.status_range.get(key)) and dp_type.type == "Json") or ( + (dp_type := device.function.get(key)) and dp_type.type == "Json" + ): device.status[key] = json_dumps(value) return device @pytest.fixture -def mock_listener( - hass: HomeAssistant, mock_manager: ManagerCompat -) -> MockDeviceListener: +def mock_listener(hass: HomeAssistant, mock_manager: Manager) -> MockDeviceListener: """Create a DeviceListener for testing.""" listener = MockDeviceListener(hass, mock_manager) mock_manager.add_device_listener(listener) diff --git a/tests/components/tuya/fixtures/cjkg_uenof8jd.json b/tests/components/tuya/fixtures/cjkg_uenof8jd.json new file mode 100644 index 00000000000..b7fbef51226 --- /dev/null +++ b/tests/components/tuya/fixtures/cjkg_uenof8jd.json @@ -0,0 +1,171 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart Switch", + "category": "cjkg", + "product_id": "uenof8jd", + "product_name": "Smart Switch", + "online": false, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-09-05T21:27:51+00:00", + "create_time": "2025-09-05T21:27:51+00:00", + "update_time": "2025-09-05T21:27:51+00:00", + "function": { + "scene_1": { + "type": "Enum", + "value": { + "range": ["scene"] + } + }, + "scene_2": { + "type": "Enum", + "value": { + "range": ["scene"] + } + }, + "mode_1": { + "type": "Enum", + "value": { + "range": ["switch_1", "scene_1"] + } + }, + "mode_2": { + "type": "Enum", + "value": { + "range": ["switch_2", "scene_2"] + } + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + } + }, + "status_range": { + "scene_1": { + "type": "Enum", + "value": { + "range": ["scene"] + } + }, + "scene_2": { + "type": "Enum", + "value": { + "range": ["scene"] + } + }, + "mode_1": { + "type": "Enum", + "value": { + "range": ["switch_1", "scene_1"] + } + }, + "mode_2": { + "type": "Enum", + "value": { + "range": ["switch_2", "scene_2"] + } + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + } + }, + "status": { + "scene_1": "scene", + "scene_2": "scene", + "mode_1": "switch_1", + "mode_2": "switch_2", + "switch_1": false, + "switch_2": false, + "countdown_1": 0, + "countdown_2": 0, + "switch_backlight": true, + "light_mode": "pos", + "relay_status": "power_off" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cl_lfkr93x0ukp5gaia.json b/tests/components/tuya/fixtures/cl_lfkr93x0ukp5gaia.json new file mode 100644 index 00000000000..197c9e9ac51 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_lfkr93x0ukp5gaia.json @@ -0,0 +1,138 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Projector Screen", + "category": "cl", + "product_id": "lfkr93x0ukp5gaia", + "product_name": "VIVIDSTORM SCREEN", + "online": true, + "sub": false, + "time_zone": "-05:00", + "active_time": "2025-05-02T23:54:36+00:00", + "create_time": "2025-05-02T23:54:36+00:00", + "update_time": "2025-05-02T23:54:36+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "border": { + "type": "Enum", + "value": { + "range": ["up", "down", "up_delete", "down_delete", "remove_top_bottom"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opening", "closing"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "time_total": { + "type": "Integer", + "value": { + "unit": "ms", + "min": 0, + "max": 120000, + "scale": 0, + "step": 1 + } + }, + "situation_set": { + "type": "Enum", + "value": { + "range": ["fully_open", "fully_close"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["motor_fault"] + } + }, + "border": { + "type": "Enum", + "value": { + "range": ["up", "down", "up_delete", "down_delete", "remove_top_bottom"] + } + } + }, + "status": { + "control": "close", + "percent_control": 100, + "percent_state": 0, + "control_back_mode": "forward", + "work_state": "opening", + "countdown_left": 0, + "time_total": 0, + "situation_set": "fully_open", + "fault": 0, + "border": "down" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cl_rD7uqAAgQOpSA2Rx.json b/tests/components/tuya/fixtures/cl_rD7uqAAgQOpSA2Rx.json new file mode 100644 index 00000000000..d50a48766a5 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_rD7uqAAgQOpSA2Rx.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kit-Blinds", + "category": "cl", + "product_id": "rD7uqAAgQOpSA2Rx", + "product_name": "Wi-Fi Curtian Switch", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-04-04T08:17:44+00:00", + "create_time": "2020-04-04T08:17:44+00:00", + "update_time": "2020-04-04T08:17:44+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "close", "stop"] + } + } + }, + "status": { + "control": "open" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/clkg_wltqkykhni0papzj.json b/tests/components/tuya/fixtures/clkg_wltqkykhni0papzj.json new file mode 100644 index 00000000000..7e04bb3663a --- /dev/null +++ b/tests/components/tuya/fixtures/clkg_wltqkykhni0papzj.json @@ -0,0 +1,114 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Roller shutter Living Room", + "category": "clkg", + "product_id": "wltqkykhni0papzj", + "product_name": "Curtain switch", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-06T17:12:56+00:00", + "create_time": "2025-08-06T17:12:56+00:00", + "update_time": "2025-08-06T17:12:56+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "tr_timecon": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 180, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "tr_timecon": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 180, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "stop", + "percent_control": 75, + "cur_calibration": "start", + "switch_backlight": false, + "control_back_mode": "back", + "tr_timecon": 25 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/clkg_xqvhthwkbmp3aghs.json b/tests/components/tuya/fixtures/clkg_xqvhthwkbmp3aghs.json new file mode 100644 index 00000000000..0f90f2af3c2 --- /dev/null +++ b/tests/components/tuya/fixtures/clkg_xqvhthwkbmp3aghs.json @@ -0,0 +1,114 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Pergola", + "category": "clkg", + "product_id": "xqvhthwkbmp3aghs", + "product_name": "Curtain switch", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-05-15T12:00:44+00:00", + "create_time": "2023-05-15T12:00:44+00:00", + "update_time": "2023-05-15T12:00:44+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "tr_timecon": { + "type": "Integer", + "value": { + "unit": "s", + "min": 10, + "max": 240, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "tr_timecon": { + "type": "Integer", + "value": { + "unit": "s", + "min": 10, + "max": 240, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "stop", + "percent_control": 0, + "cur_calibration": "end", + "switch_backlight": false, + "control_back_mode": "forward", + "tr_timecon": 32 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/co2bj_yakol79dibtswovc.json b/tests/components/tuya/fixtures/co2bj_yakol79dibtswovc.json new file mode 100644 index 00000000000..316ad9b6955 --- /dev/null +++ b/tests/components/tuya/fixtures/co2bj_yakol79dibtswovc.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "PTH-9CW 32", + "category": "co2bj", + "product_id": "yakol79dibtswovc", + "product_name": "PTH-9CW(QC)", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2025-09-05T16:29:08+00:00", + "create_time": "2025-09-05T16:29:08+00:00", + "update_time": "2025-09-05T16:29:08+00:00", + "function": {}, + "status_range": { + "co2_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 5000, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -40, + "max": 125, + "scale": 0, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "co2_value": 450, + "temp_current": 25, + "humidity_value": 43 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json index c4657f30012..288430b635d 100644 --- a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json +++ b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json @@ -1,18 +1,14 @@ { - "endpoint": "https://apigw.tuyaus.com", - "mqtt_connected": true, - "disabled_by": null, - "disabled_polling": false, "name": "AQI", "category": "co2bj", "product_id": "yrr3eiyiacm31ski", "product_name": "AIR_DETECTOR ", "online": true, "sub": false, - "time_zone": "+07:00", - "active_time": "2025-01-02T05:14:50+00:00", - "create_time": "2025-01-02T05:14:50+00:00", - "update_time": "2025-01-02T05:14:50+00:00", + "time_zone": "+02:00", + "active_time": "2025-08-31T07:43:45+00:00", + "create_time": "2025-08-31T07:43:45+00:00", + "update_time": "2025-09-05T07:29:11+00:00", "function": { "alarm_volume": { "type": "Enum", @@ -43,6 +39,62 @@ "scale": 0, "step": 1 } + }, + "alarm_ringtone": { + "type": "Enum", + "value": { + "range": [ + "ringtone_1", + "ringtone_2", + "ringtone_3", + "ringtone_4", + "ringtone_5" + ] + } + }, + "maxco2_set": { + "type": "Integer", + "value": { + "max": 2000, + "min": 800, + "scale": 0, + "step": 100, + "unit": "ppm" + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "pm25_set": { + "type": "Integer", + "value": { + "max": 75, + "min": 15, + "scale": 0, + "step": 5, + "unit": "\u03bcg/m3" + } + }, + "check_time": { + "type": "Boolean", + "value": {} + }, + "screen_sleep": { + "type": "Boolean", + "value": {} + }, + "screen_sleep_time": { + "type": "Integer", + "value": { + "max": 300, + "min": 10, + "scale": 0, + "step": 10, + "unit": "s" + } } }, "status_range": { @@ -151,21 +203,82 @@ "scale": 3, "step": 1 } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["normal", "charge"] + } + }, + "pm10": { + "type": "Integer", + "value": { + "max": 1000, + "min": 0, + "scale": 0, + "step": 1, + "unit": "ug/m3" + } + }, + "pm25": { + "type": "Integer", + "value": { + "max": 1000, + "min": 0, + "scale": 0, + "step": 1, + "unit": "ug/m3" + } + }, + "humidity_current": { + "type": "Integer", + "value": { + "max": 100, + "min": 0, + "scale": 0, + "step": 1, + "unit": "" + } + }, + "air_quality": { + "type": "Enum", + "value": { + "range": ["great", "mild", "good", "medium", "severe"] + } + }, + "pm25_state": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } } }, "status": { "co2_state": "normal", - "co2_value": 541, - "alarm_volume": "low", + "co2_value": 419, + "alarm_volume": "mute", "alarm_time": 1, "alarm_switch": false, "battery_percentage": 100, - "alarm_bright": 98, - "temp_current": 26, - "humidity_value": 53, - "pm25_value": 17, - "voc_value": 18, - "ch2o_value": 2 + "alarm_bright": 0, + "temp_current": 24, + "humidity_value": 54, + "pm25_value": 7, + "voc_value": 71, + "ch2o_value": 6, + "alarm_ringtone": "ringtone_1", + "battery_state": "normal", + "maxco2_set": 1500, + "temp_unit_convert": "c", + "pm10": 8, + "pm25": 5, + "humidity_current": 54, + "air_quality": "great", + "pm25_set": 40, + "pm25_state": "normal", + "check_time": false, + "screen_sleep": true, + "screen_sleep_time": 180 }, "set_up": true, "support_local": true diff --git a/tests/components/tuya/fixtures/cs_b9oyi2yofflroq1g.json b/tests/components/tuya/fixtures/cs_b9oyi2yofflroq1g.json new file mode 100644 index 00000000000..ad35e3c0e45 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_b9oyi2yofflroq1g.json @@ -0,0 +1,134 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Living room dehumidifier", + "category": "cs", + "product_id": "b9oyi2yofflroq1g", + "product_name": "Dehumidifier ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-02-25T10:34:41+00:00", + "create_time": "2025-02-25T10:34:41+00:00", + "update_time": "2025-02-25T10:34:41+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "swing": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "swing": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "switch": false, + "dehumidify_set_value": 47, + "fan_speed_enum": "high", + "humidity_indoor": 48, + "swing": true, + "anion": false, + "uv": false, + "child_lock": false, + "countdown_set": "cancel", + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_eguoms25tkxtf5u8.json b/tests/components/tuya/fixtures/cs_eguoms25tkxtf5u8.json new file mode 100644 index 00000000000..d288905fc21 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_eguoms25tkxtf5u8.json @@ -0,0 +1,88 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Arida Stavern ", + "category": "cs", + "product_id": "eguoms25tkxtf5u8", + "product_name": "Arida Stavern ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-09-14T15:40:04+00:00", + "create_time": "2025-09-14T15:40:04+00:00", + "update_time": "2025-09-14T15:40:04+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_enum": { + "type": "Enum", + "value": { + "range": ["40", "50"] + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["1h", "2h", "3h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_enum": { + "type": "Enum", + "value": { + "range": ["40", "50"] + } + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_indoor": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["1h", "2h", "3h"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["TILTED", "CHECK", "E_Saving", "FULL"] + } + } + }, + "status": { + "switch": true, + "dehumidify_set_enum": 60, + "humidity_indoor": 61, + "temp_indoor": 16, + "countdown_set": "CANCEL", + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cwwsq_lxfvx41gqdotrkgi.json b/tests/components/tuya/fixtures/cwwsq_lxfvx41gqdotrkgi.json new file mode 100644 index 00000000000..c323b2be993 --- /dev/null +++ b/tests/components/tuya/fixtures/cwwsq_lxfvx41gqdotrkgi.json @@ -0,0 +1,111 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Cat Feeder", + "category": "cwwsq", + "product_id": "lxfvx41gqdotrkgi", + "product_name": "Wi-Fi Pet Feeder", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2025-08-19T23:39:22+00:00", + "create_time": "2025-08-19T23:39:22+00:00", + "update_time": "2025-08-19T23:39:22+00:00", + "function": { + "meal_plan": { + "type": "Raw", + "value": {} + }, + "manual_feed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "voice_times": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "meal_plan": { + "type": "Raw", + "value": {} + }, + "manual_feed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "feed_state": { + "type": "Enum", + "value": { + "range": ["standby", "feeding"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "feed_report": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 255, + "scale": 0, + "step": 1 + } + }, + "voice_times": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "meal_plan": "fwceBQF/DgACAX8UAAQB", + "manual_feed": 5, + "feed_state": "standby", + "factory_reset": false, + "feed_report": 1, + "voice_times": 0, + "light": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_0fHWRe8ULjtmnBNd.json b/tests/components/tuya/fixtures/cz_0fHWRe8ULjtmnBNd.json new file mode 100644 index 00000000000..ea3e338ac1b --- /dev/null +++ b/tests/components/tuya/fixtures/cz_0fHWRe8ULjtmnBNd.json @@ -0,0 +1,149 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Weihnachten3", + "category": "cz", + "product_id": "0fHWRe8ULjtmnBNd", + "product_name": "SP22-10A", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2018-12-07T12:58:37+00:00", + "create_time": "2018-12-07T12:58:37+00:00", + "update_time": "2018-12-07T12:58:37+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 18, + "cur_power": 21, + "cur_voltage": 2351, + "voltage_coe": 638, + "electric_coe": 31090, + "power_coe": 17883, + "electricity_coe": 1165, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_79a7z01v3n35kytb.json b/tests/components/tuya/fixtures/cz_79a7z01v3n35kytb.json new file mode 100644 index 00000000000..efd232bd66d --- /dev/null +++ b/tests/components/tuya/fixtures/cz_79a7z01v3n35kytb.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Double Digital Meter", + "category": "cz", + "product_id": "79a7z01v3n35kytb", + "product_name": "Double Digital Meter", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-05T16:04:41+00:00", + "create_time": "2025-07-05T16:04:41+00:00", + "update_time": "2025-07-05T16:04:41+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_IGzCi97RpN2Lf9cu.json b/tests/components/tuya/fixtures/cz_IGzCi97RpN2Lf9cu.json new file mode 100644 index 00000000000..4f2a7287a3b --- /dev/null +++ b/tests/components/tuya/fixtures/cz_IGzCi97RpN2Lf9cu.json @@ -0,0 +1,149 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "N4-Auto", + "category": "cz", + "product_id": "IGzCi97RpN2Lf9cu", + "product_name": "Smart Socket", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-11-15T07:45:07+00:00", + "create_time": "2020-11-15T07:45:07+00:00", + "update_time": "2020-11-15T07:45:07+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 14, + "cur_power": 16, + "cur_voltage": 2287, + "voltage_coe": 757, + "electric_coe": 31906, + "power_coe": 21760, + "electricity_coe": 960, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_PGEkBctAbtzKOZng.json b/tests/components/tuya/fixtures/cz_PGEkBctAbtzKOZng.json new file mode 100644 index 00000000000..16623e0dc28 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_PGEkBctAbtzKOZng.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Din", + "category": "cz", + "product_id": "PGEkBctAbtzKOZng", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2018-07-13T13:18:44+00:00", + "create_time": "2018-07-13T13:18:44+00:00", + "update_time": "2018-07-13T13:18:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_dhto3y4uachr1wll.json b/tests/components/tuya/fixtures/cz_dhto3y4uachr1wll.json new file mode 100644 index 00000000000..2846efc6f1b --- /dev/null +++ b/tests/components/tuya/fixtures/cz_dhto3y4uachr1wll.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Meter", + "category": "cz", + "product_id": "dhto3y4uachr1wll", + "product_name": "Double Digital Meter", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-02T18:53:11+00:00", + "create_time": "2025-07-02T18:53:11+00:00", + "update_time": "2025-07-02T18:53:11+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_mQUhiTg9kwydBFBd.json b/tests/components/tuya/fixtures/cz_mQUhiTg9kwydBFBd.json new file mode 100644 index 00000000000..1dc27226104 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_mQUhiTg9kwydBFBd.json @@ -0,0 +1,87 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Waschmaschine", + "category": "cz", + "product_id": "mQUhiTg9kwydBFBd", + "product_name": "Smart Socket", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2018-08-13T17:59:14+00:00", + "create_time": "2018-08-13T17:59:14+00:00", + "update_time": "2018-08-13T17:59:14+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 0, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 3000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown_1": 0, + "cur_current": 1, + "cur_power": 10455, + "cur_voltage": 2381 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_piuensvr.json b/tests/components/tuya/fixtures/cz_piuensvr.json new file mode 100644 index 00000000000..8489f44da8f --- /dev/null +++ b/tests/components/tuya/fixtures/cz_piuensvr.json @@ -0,0 +1,33 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Signal repeater", + "category": "cz", + "product_id": "piuensvr", + "product_name": "Signal repeater", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-16T17:52:11+00:00", + "create_time": "2025-07-16T17:52:11+00:00", + "update_time": "2025-07-16T17:52:11+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_qxJSyTLEtX5WrzA9.json b/tests/components/tuya/fixtures/cz_qxJSyTLEtX5WrzA9.json new file mode 100644 index 00000000000..7581500a3c9 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_qxJSyTLEtX5WrzA9.json @@ -0,0 +1,87 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "LivR", + "category": "cz", + "product_id": "qxJSyTLEtX5WrzA9", + "product_name": "Mini Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2018-02-21T13:32:25+00:00", + "create_time": "2018-02-21T13:32:25+00:00", + "update_time": "2018-02-21T13:32:25+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "\u79d2", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 0, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 3000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown_1": 0, + "cur_current": 81, + "cur_power": 83, + "cur_voltage": 2352 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_vrbpx6h7fsi5mujb.json b/tests/components/tuya/fixtures/cz_vrbpx6h7fsi5mujb.json new file mode 100644 index 00000000000..770d8fb7c04 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_vrbpx6h7fsi5mujb.json @@ -0,0 +1,223 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "\u63a5HA\u53cc\u5411\u8ba1\u91cf\u63d2\u5ea7", + "category": "cz", + "product_id": "vrbpx6h7fsi5mujb", + "product_name": "\u63a5HA\u53cc\u5411\u8ba1\u91cf\u63d2\u5ea7", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2025-07-17T09:18:54+00:00", + "create_time": "2025-07-17T09:18:54+00:00", + "update_time": "2025-07-17T09:18:54+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999999, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + }, + "energy_status": { + "type": "Enum", + "value": { + "range": ["consumption", "production"] + } + }, + "pro_add_ele": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999999, + "scale": 3, + "step": 100 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 900, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 0, + "voltage_coe": 0, + "electric_coe": 0, + "power_coe": 0, + "electricity_coe": 0, + "fault": 0, + "relay_status": "power_off", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "", + "energy_status": "consumption", + "pro_add_ele": 1100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dlq_dikb3dp6.json b/tests/components/tuya/fixtures/dlq_dikb3dp6.json new file mode 100644 index 00000000000..a32878b8b52 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_dikb3dp6.json @@ -0,0 +1,146 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Medidor de Energia", + "category": "dlq", + "product_id": "dikb3dp6", + "product_name": "Metering_3PN_ZB", + "online": true, + "sub": true, + "time_zone": "-03:00", + "active_time": "2025-09-01T18:39:27+00:00", + "create_time": "2025-09-01T18:39:27+00:00", + "update_time": "2025-09-01T18:39:27+00:00", + "function": { + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW.h", + "min": 0, + "max": 999999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm" + ] + } + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW.h", + "min": 0, + "max": 999999999, + "scale": 2, + "step": 1 + } + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status": { + "forward_energy_total": 13540, + "phase_a": "CREANUkADG8=", + "phase_b": "CTIAVfcAFGw=", + "phase_c": "CPQAI58ACBA=", + "fault": 16896, + "energy_reset": "empty", + "alarm_set_1": "BwAAGQ==", + "alarm_set_2": "AQAAPwIBAA8DAQD9BAAAtAUAAAAHAQAA", + "breaker_number": "dik24350001", + "reverse_energy_total": 12552, + "supply_frequency": 6002, + "online_state": "online" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/jsq_r492ifwk6f2ssptb.json b/tests/components/tuya/fixtures/jsq_r492ifwk6f2ssptb.json new file mode 100644 index 00000000000..ed8595e8655 --- /dev/null +++ b/tests/components/tuya/fixtures/jsq_r492ifwk6f2ssptb.json @@ -0,0 +1,86 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "KLARTA HUMEA", + "category": "jsq", + "product_id": "r492ifwk6f2ssptb", + "product_name": "KLARTA HUMEA", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-09-03T09:32:08+00:00", + "create_time": "2024-09-03T09:32:08+00:00", + "update_time": "2024-09-03T09:32:08+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "sleep": { + "type": "Boolean", + "value": {} + }, + "level": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9" + ] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "sleep": { + "type": "Boolean", + "value": {} + }, + "humidity_current": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9" + ] + } + } + }, + "status": { + "switch": false, + "sleep": false, + "level": "level_1", + "humidity_current": 76 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_oxslv1c9.json b/tests/components/tuya/fixtures/mcs_oxslv1c9.json new file mode 100644 index 00000000000..20a5060df69 --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_oxslv1c9.json @@ -0,0 +1,39 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Window downstairs", + "category": "mcs", + "product_id": "oxslv1c9", + "product_name": "Contact Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-03-27T08:28:40+00:00", + "create_time": "2025-03-27T08:28:40+00:00", + "update_time": "2025-03-27T08:28:40+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "doorcontact_state": false, + "battery_percentage": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/msp_3ddulzljdjjwkhoy.json b/tests/components/tuya/fixtures/msp_3ddulzljdjjwkhoy.json new file mode 100644 index 00000000000..7bde995a2a3 --- /dev/null +++ b/tests/components/tuya/fixtures/msp_3ddulzljdjjwkhoy.json @@ -0,0 +1,149 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kattenbak", + "category": "msp", + "product_id": "3ddulzljdjjwkhoy", + "product_name": "ZEDAR K1200", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-08T18:02:16+00:00", + "create_time": "2025-07-08T18:02:16+00:00", + "update_time": "2025-07-08T18:02:16+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "auto_clean": { + "type": "Boolean", + "value": {} + }, + "delay_clean_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 600, + "scale": 0, + "step": 1 + } + }, + "manual_clean": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "status": { + "type": "Enum", + "value": { + "range": ["standby", "sleep", "uv"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "auto_clean": { + "type": "Boolean", + "value": {} + }, + "delay_clean_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 600, + "scale": 0, + "step": 1 + } + }, + "cat_weight": { + "type": "Integer", + "value": { + "unit": "g", + "min": 0, + "max": 10000, + "scale": 0, + "step": 1 + } + }, + "excretion_times_day": { + "type": "Integer", + "value": { + "unit": "times", + "min": 0, + "max": 2, + "scale": 0, + "step": 1 + } + }, + "excretion_time_day": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 600, + "scale": 0, + "step": 1 + } + }, + "manual_clean": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "motor_fault", + "g_sensor_fault", + "full_fault", + "box_out", + "filter_fault" + ] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "status": { + "type": "Enum", + "value": { + "range": ["standby", "sleep", "uv"] + } + } + }, + "status": { + "switch": false, + "auto_clean": true, + "delay_clean_time": 90, + "cat_weight": 0, + "excretion_times_day": 1, + "excretion_time_day": 35, + "manual_clean": false, + "light": false, + "fault": 0, + "factory_reset": false, + "status": "standby" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pir_j5jgnjvdaczeb6dc.json b/tests/components/tuya/fixtures/pir_j5jgnjvdaczeb6dc.json new file mode 100644 index 00000000000..338a9b524c5 --- /dev/null +++ b/tests/components/tuya/fixtures/pir_j5jgnjvdaczeb6dc.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "QNECT WI-FI PIR SENSOR", + "category": "pir", + "product_id": "j5jgnjvdaczeb6dc", + "product_name": "QNECT WI-FI PIR SENSOR", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-06-21T07:10:11+00:00", + "create_time": "2024-06-21T07:10:11+00:00", + "update_time": "2024-06-21T07:10:11+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qt_TtXKwTMwiPpURWLJ.json b/tests/components/tuya/fixtures/qt_TtXKwTMwiPpURWLJ.json new file mode 100644 index 00000000000..d66a997ee13 --- /dev/null +++ b/tests/components/tuya/fixtures/qt_TtXKwTMwiPpURWLJ.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Dining-Blinds", + "category": "qt", + "product_id": "TtXKwTMwiPpURWLJ", + "product_name": "Curtain switch", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2019-06-07T09:33:41+00:00", + "create_time": "2019-06-07T09:33:41+00:00", + "update_time": "2019-06-07T09:33:41+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + } + }, + "status": { + "control": "open" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qxj_xbwbniyt6bgws9ia.json b/tests/components/tuya/fixtures/qxj_xbwbniyt6bgws9ia.json new file mode 100644 index 00000000000..bce405a7558 --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_xbwbniyt6bgws9ia.json @@ -0,0 +1,265 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "SWS 16600 WiFi SH", + "category": "qxj", + "product_id": "xbwbniyt6bgws9ia", + "product_name": "SWS 16600 WiFi SH", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-17T14:39:27+00:00", + "create_time": "2025-03-17T14:39:27+00:00", + "update_time": "2025-03-17T14:39:27+00:00", + "function": {}, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 601, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 101, + "scale": 0, + "step": 1 + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 601, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 101, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_1": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 601, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_1": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 101, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 601, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_2": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 101, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_3": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 601, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_3": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 101, + "scale": 0, + "step": 1 + } + }, + "atmospheric_pressture": { + "type": "Integer", + "value": { + "unit": "", + "min": 3000, + "max": 12001, + "scale": 1, + "step": 1 + } + }, + "pressure_drop": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 0, + "max": 16, + "scale": 0, + "step": 1 + } + }, + "windspeed_avg": { + "type": "Integer", + "value": { + "unit": "km/h", + "min": 0, + "max": 701, + "scale": 1, + "step": 1 + } + }, + "windspeed_gust": { + "type": "Integer", + "value": { + "unit": "km/h", + "min": 0, + "max": 701, + "scale": 1, + "step": 1 + } + }, + "rain_24h": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 1000001, + "scale": 3, + "step": 1 + } + }, + "rain_rate": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 1000001, + "scale": 3, + "step": 1 + } + }, + "uv_index": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 181, + "scale": 1, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 238001, + "scale": 0, + "step": 100 + } + }, + "dew_point_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 801, + "scale": 1, + "step": 1 + } + }, + "feellike_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 501, + "scale": 1, + "step": 1 + } + }, + "heat_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 260, + "max": 501, + "scale": 1, + "step": 1 + } + }, + "windchill_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 601, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "temp_current": 245, + "humidity_value": 47, + "temp_current_external": 207, + "humidity_outdoor": 68, + "temp_current_external_1": 601, + "humidity_outdoor_1": 101, + "temp_current_external_2": 601, + "humidity_outdoor_2": 101, + "temp_current_external_3": 601, + "humidity_outdoor_3": 101, + "atmospheric_pressture": 10078, + "pressure_drop": 0, + "windspeed_avg": 0, + "windspeed_gust": 0, + "rain_24h": 0, + "rain_rate": 0, + "uv_index": 0, + "bright_value": 7480, + "dew_point_temp": 145, + "feellike_temp": 207, + "heat_index": 501, + "windchill_index": 205 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/rs_d7woucobqi8ncacf.json b/tests/components/tuya/fixtures/rs_d7woucobqi8ncacf.json new file mode 100644 index 00000000000..11953b857b9 --- /dev/null +++ b/tests/components/tuya/fixtures/rs_d7woucobqi8ncacf.json @@ -0,0 +1,57 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Geti Solar PV Water Heater", + "category": "rs", + "product_id": "d7woucobqi8ncacf", + "product_name": "Geti Solar PV Water Heater", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-16T10:03:14+00:00", + "create_time": "2025-08-16T10:03:14+00:00", + "update_time": "2025-08-16T10:03:14+00:00", + "function": {}, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "power_consumption": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 10000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "bitmap", + "value": { + "label": [ + "pv_voltage_high", + "ac_voltage_high", + "water_temp_high", + "water_temp_unknown" + ] + } + } + }, + "status": { + "temp_current": 60, + "power_consumption": 86, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sgbj_DYgId0sz6zWlmmYu.json b/tests/components/tuya/fixtures/sgbj_DYgId0sz6zWlmmYu.json new file mode 100644 index 00000000000..7f84e0b7c8e --- /dev/null +++ b/tests/components/tuya/fixtures/sgbj_DYgId0sz6zWlmmYu.json @@ -0,0 +1,74 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Siren", + "category": "sgbj", + "product_id": "DYgId0sz6zWlmmYu", + "product_name": "Siren", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-08T20:44:34+00:00", + "create_time": "2025-03-08T20:44:34+00:00", + "update_time": "2025-03-08T20:44:34+00:00", + "function": { + "Alarmtype": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] + } + }, + "AlarmPeriod": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "AlarmSwitch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "Alarmtype": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] + } + }, + "BatteryStatus": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "AlarmSwitch": { + "type": "Boolean", + "value": {} + }, + "AlarmPeriod": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 60, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "BatteryStatus": 4, + "Alarmtype": 9, + "AlarmPeriod": 10, + "AlarmSwitch": false + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sjz_ftbc8rp8ipksdfpv.json b/tests/components/tuya/fixtures/sjz_ftbc8rp8ipksdfpv.json new file mode 100644 index 00000000000..9378ab15fb5 --- /dev/null +++ b/tests/components/tuya/fixtures/sjz_ftbc8rp8ipksdfpv.json @@ -0,0 +1,143 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "mesa", + "category": "sjz", + "product_id": "ftbc8rp8ipksdfpv", + "product_name": "geniodesk", + "online": true, + "sub": false, + "time_zone": "-03:00", + "active_time": "2025-06-16T19:48:57+00:00", + "create_time": "2025-06-16T19:48:57+00:00", + "update_time": "2025-06-22T21:26:09+00:00", + "function": { + "up_down": { + "type": "Enum", + "value": { + "range": ["up", "down", "stop"] + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["level_1", "level_2", "level_3", "level_4"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "height": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 66, + "max": 131, + "scale": 0, + "step": 1 + } + }, + "height_inch": { + "type": "Integer", + "value": { + "max": 520, + "min": 250, + "scale": 1, + "step": 1, + "unit": "inch" + } + }, + "metric_inch_flag": { + "type": "Boolean", + "value": {} + }, + "percent_high": { + "type": "Integer", + "value": { + "max": 100, + "min": 0, + "scale": 0, + "step": 1, + "unit": "" + } + }, + "clock_time": { + "type": "Integer", + "value": { + "max": 150, + "min": 30, + "scale": 0, + "step": 30, + "unit": "min" + } + }, + "clock_flag": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "up_down": { + "type": "Enum", + "value": { + "range": ["up", "down", "stop"] + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["up", "down", "stop"] + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["level_1", "level_2", "level_3", "level_4"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Raw", + "value": { + "label": ["ov_cr", "motor_fault"], + "maxlen": 2 + } + }, + "height": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 66, + "max": 131, + "scale": 0, + "step": 1 + } + }, + "flag": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "up_down": "stop", + "work_state": "stop", + "level": "level_1", + "child_lock": false, + "fault": 0, + "height": 77, + "height_inch": 250, + "metric_inch_flag": true, + "percent_high": 0, + "flag": false, + "clock_time": 30, + "clock_flag": false + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sp_6bmk1remyscwyx6i.json b/tests/components/tuya/fixtures/sp_6bmk1remyscwyx6i.json new file mode 100644 index 00000000000..ca5a8dff998 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_6bmk1remyscwyx6i.json @@ -0,0 +1,229 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Mirilla puerta", + "category": "sp", + "product_id": "6bmk1remyscwyx6i", + "product_name": "Smart DoorBell \uff08WiFi\uff09", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-03-05T12:13:24+00:00", + "create_time": "2024-03-05T12:13:24+00:00", + "update_time": "2025-09-06T07:21:05+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["ir_mode", "color_mode"] + } + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "unit": "", + "min": 10, + "max": 50, + "scale": 1, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3"] + } + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "%", + "min": 1, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "basic_anti_flicker": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 5, + "scale": 1, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "sd_format_state": { + "type": "Integer", + "value": { + "unit": "", + "min": -20000, + "max": 20000, + "scale": 1, + "step": 1 + } + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["ir_mode", "color_mode"] + } + }, + "wireless_electricity": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 100, + "scale": 1, + "step": 1 + } + }, + "wireless_powermode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "unit": "", + "min": 10, + "max": 50, + "scale": 1, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3"] + } + }, + "doorbell_pic": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "%", + "min": 1, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "alarm_message": { + "type": "String", + "value": {} + }, + "basic_anti_flicker": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + } + }, + "status": { + "basic_flip": false, + "basic_osd": true, + "sd_storge": "31147664|31043136|104528", + "sd_status": 1, + "sd_format": true, + "movement_detect_pic": "**REDACTED**", + "sd_format_state": 0, + "nightvision_mode": "color_mode", + "wireless_electricity": 100, + "wireless_powermode": 1, + "wireless_lowpower": 10, + "wireless_awake": false, + "pir_switch": 3, + "doorbell_pic": "", + "basic_device_volume": 51, + "humanoid_filter": false, + "alarm_message": "**REDACTED**", + "basic_anti_flicker": 0, + "ipc_work_mode": 0 + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/swtz_3rzngbyy.json b/tests/components/tuya/fixtures/swtz_3rzngbyy.json new file mode 100644 index 00000000000..9eab932c4cb --- /dev/null +++ b/tests/components/tuya/fixtures/swtz_3rzngbyy.json @@ -0,0 +1,116 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Grillh\u0151m\u00e9r\u0151", + "category": "swtz", + "product_id": "3rzngbyy", + "product_name": "Cooking Thermometer", + "online": false, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-08-17T19:59:22+00:00", + "create_time": "2025-08-17T19:59:22+00:00", + "update_time": "2025-08-17T19:59:22+00:00", + "function": { + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "cook_temperature_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "temp_current_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "cook_temperature_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "battery_percentage": 100, + "temp_current": 290, + "temp_current_2": 290, + "cook_temperature": -300, + "cook_temperature_2": -300, + "temp_unit_convert": "c" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/szjcy_u5xgcpcngk3pfxb4.json b/tests/components/tuya/fixtures/szjcy_u5xgcpcngk3pfxb4.json new file mode 100644 index 00000000000..b2bba20686e --- /dev/null +++ b/tests/components/tuya/fixtures/szjcy_u5xgcpcngk3pfxb4.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "YINMIK Water Quality Tester", + "category": "szjcy", + "product_id": "u5xgcpcngk3pfxb4", + "product_name": "YINMIK Water Quality Tester", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-09-02T06:27:22+00:00", + "create_time": "2025-09-02T06:27:22+00:00", + "update_time": "2025-09-02T06:27:22+00:00", + "function": {}, + "status_range": { + "tds_in": { + "type": "Integer", + "value": { + "unit": "ppt", + "min": 0, + "max": 100000, + "scale": 3, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 800, + "scale": 1, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "tds_in": 476, + "temp_current": 412, + "battery_percentage": 0 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_p6sqiuesvhmhvv4f.json b/tests/components/tuya/fixtures/tdq_p6sqiuesvhmhvv4f.json new file mode 100644 index 00000000000..ccaad4d5a4a --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_p6sqiuesvhmhvv4f.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Entrance Door", + "category": "tdq", + "product_id": "p6sqiuesvhmhvv4f", + "product_name": "Contact Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-27T15:14:26+00:00", + "create_time": "2025-03-27T15:14:26+00:00", + "update_time": "2025-03-27T15:14:26+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_x3o8epevyeo3z3oa.json b/tests/components/tuya/fixtures/tdq_x3o8epevyeo3z3oa.json new file mode 100644 index 00000000000..4d6831e16ad --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_x3o8epevyeo3z3oa.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Interior Bedroom Sensor", + "category": "tdq", + "product_id": "x3o8epevyeo3z3oa", + "product_name": "T & H Sensor", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-28T12:38:48+00:00", + "create_time": "2025-06-28T12:38:48+00:00", + "update_time": "2025-06-28T12:38:48+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wfcon_plp0gnfcacdeqk5o.json b/tests/components/tuya/fixtures/wfcon_plp0gnfcacdeqk5o.json new file mode 100644 index 00000000000..2aba962e586 --- /dev/null +++ b/tests/components/tuya/fixtures/wfcon_plp0gnfcacdeqk5o.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Zigbee Gateway", + "category": "wfcon", + "product_id": "plp0gnfcacdeqk5o", + "product_name": "Zigbee Gateway", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-10-14T06:02:39+00:00", + "create_time": "2023-10-14T06:02:39+00:00", + "update_time": "2023-10-14T06:02:39+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_IAYz2WK1th0cMLmL.json b/tests/components/tuya/fixtures/wk_IAYz2WK1th0cMLmL.json new file mode 100644 index 00000000000..0eacf2695ef --- /dev/null +++ b/tests/components/tuya/fixtures/wk_IAYz2WK1th0cMLmL.json @@ -0,0 +1,130 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "El termostato de la cocina", + "category": "wk", + "product_id": "IAYz2WK1th0cMLmL", + "product_name": "thermostat", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2018-12-04T17:50:07+00:00", + "create_time": "2018-12-04T17:50:07+00:00", + "update_time": "2025-09-03T07:44:16+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "eco": { + "type": "Boolean", + "value": {} + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 100, + "scale": 0, + "step": 5 + } + }, + "Mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "program": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "tempSwitch": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "TempSet": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 10, + "max": 70, + "scale": 1, + "step": 5 + } + } + }, + "status_range": { + "eco": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 100, + "scale": 0, + "step": 5 + } + }, + "floorTemp": { + "type": "Integer", + "value": { + "max": 198, + "min": 0, + "scale": 0, + "step": 5, + "unit": "\u2103" + } + }, + "floortempFunction": { + "type": "Boolean", + "value": {} + }, + "TempSet": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 10, + "max": 70, + "scale": 1, + "step": 5 + } + } + }, + "status": { + "switch": false, + "upper_temp": 55, + "eco": true, + "child_lock": false, + "Mode": 1, + "program": "DwYoDwceHhQoORceOhceOxceAAkoAAoeHhQoORceOhceOxceAAkoAAoeHhQoORceOhceOxce", + "floorTemp": 0, + "tempSwitch": 0, + "floortempFunction": true, + "TempSet": 41 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_cpmgn2cf.json b/tests/components/tuya/fixtures/wk_cpmgn2cf.json new file mode 100644 index 00000000000..4448a2b7d5d --- /dev/null +++ b/tests/components/tuya/fixtures/wk_cpmgn2cf.json @@ -0,0 +1,85 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bathroom radiator", + "category": "wk", + "product_id": "cpmgn2cf", + "product_name": "SmartTRV", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2025-07-20T12:12:03+00:00", + "create_time": "2025-07-20T12:12:03+00:00", + "update_time": "2025-07-20T12:12:03+00:00", + "function": { + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u00b0C", + "min": 10, + "max": 700, + "scale": 1, + "step": 5 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["holiday", "auto", "manual", "eco"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u00b0C", + "min": 10, + "max": 700, + "scale": 1, + "step": 5 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u00b0C", + "min": 0, + "max": 700, + "scale": 1, + "step": 5 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["holiday", "auto", "manual", "eco"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["1", "2", "3", "4", "5"] + } + } + }, + "status": { + "temp_set": 120, + "temp_current": 195, + "mode": "manual", + "child_lock": false, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_tfbhw0mg.json b/tests/components/tuya/fixtures/wk_tfbhw0mg.json new file mode 100644 index 00000000000..4a9186314ea --- /dev/null +++ b/tests/components/tuya/fixtures/wk_tfbhw0mg.json @@ -0,0 +1,109 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Salon", + "category": "wk", + "product_id": "tfbhw0mg", + "product_name": "ZX-5442", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-09-13T13:48:55+00:00", + "create_time": "2025-09-13T13:48:55+00:00", + "update_time": "2025-09-13T13:48:55+00:00", + "function": { + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u00b0C", + "min": 1, + "max": 59, + "scale": 1, + "step": 5 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "holiday"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "holiday_set": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u00b0C", + "min": 1, + "max": 59, + "scale": 1, + "step": 5 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u00b0C", + "min": -50, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "holiday"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["fault1", "fault2", "fault3"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 1000, + "scale": 2, + "step": 1 + } + }, + "holiday_set": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "temp_set": 59, + "temp_current": 203, + "mode": "manual", + "child_lock": false, + "fault": 0, + "battery_percentage": 117, + "holiday_set": "FAwZAAAiAAA=" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wkf_9xfjixap.json b/tests/components/tuya/fixtures/wkf_9xfjixap.json new file mode 100644 index 00000000000..88c6d6b3cc4 --- /dev/null +++ b/tests/components/tuya/fixtures/wkf_9xfjixap.json @@ -0,0 +1,85 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Empore", + "category": "wkf", + "product_id": "9xfjixap", + "product_name": "Smart Radiator Thermostat Controller", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-03-06T17:22:27+00:00", + "create_time": "2025-03-06T17:22:27+00:00", + "update_time": "2025-03-06T17:22:27+00:00", + "function": { + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "off"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 10 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "off"] + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opened", "closed"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 10 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 500, + "scale": 1, + "step": 10 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "mode": "manual", + "work_state": "opened", + "temp_set": 350, + "temp_current": 190, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wkf_p3dbf6qs.json b/tests/components/tuya/fixtures/wkf_p3dbf6qs.json new file mode 100644 index 00000000000..0e083e877f4 --- /dev/null +++ b/tests/components/tuya/fixtures/wkf_p3dbf6qs.json @@ -0,0 +1,85 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Anbau", + "category": "wkf", + "product_id": "p3dbf6qs", + "product_name": "Smart Radiator Thermostat", + "online": false, + "sub": true, + "time_zone": "+02:00", + "active_time": "2023-10-14T06:23:27+00:00", + "create_time": "2023-10-14T06:23:27+00:00", + "update_time": "2023-10-14T06:23:27+00:00", + "function": { + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "off"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 10 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "off"] + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opened", "closed"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 10 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 500, + "scale": 1, + "step": 10 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "mode": "manual", + "work_state": "opened", + "temp_set": 250, + "temp_current": 220, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wnykq_kzwdw5bpxlbs9h9g.json b/tests/components/tuya/fixtures/wnykq_kzwdw5bpxlbs9h9g.json new file mode 100644 index 00000000000..8b4209476df --- /dev/null +++ b/tests/components/tuya/fixtures/wnykq_kzwdw5bpxlbs9h9g.json @@ -0,0 +1,40 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "IR Minero", + "category": "wnykq", + "product_id": "kzwdw5bpxlbs9h9g", + "product_name": "Smart IR ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-05-07T19:07:20+00:00", + "create_time": "2023-05-07T19:07:20+00:00", + "update_time": "2025-09-03T07:44:22+00:00", + "function": { + "ir_send": { + "type": "String", + "value": { + "maxlen": 3072 + } + } + }, + "status_range": { + "ir_study_code": { + "type": "Raw", + "value": { + "maxlen": 128 + } + } + }, + "status": { + "ir_send": { + "control": "study_exit" + }, + "ir_study_code": "UAjyBn0ALQh9AMMEfQBFBBoB5wO+CmUEvAC2BrwA1QG9CtYGnABrA30A/Eh9APYGqgiwAn0AhAR9AIMEnQCiBPoAyAOdBYMEfQCEBA0I9AGcAIQJfQAyB30ADQOUUdwKfQDCBH0AZQ59AIMEfQCECX4Kowm7ALYGgwQwdQ==" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_qrztc3ev.json b/tests/components/tuya/fixtures/wsdcg_qrztc3ev.json new file mode 100644 index 00000000000..629e543706b --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_qrztc3ev.json @@ -0,0 +1,210 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Temperature and humidity sensor", + "category": "wsdcg", + "product_id": "qrztc3ev", + "product_name": "Temperature and humidity sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-03-29T14:26:44+00:00", + "create_time": "2025-03-29T14:26:44+00:00", + "update_time": "2025-03-29T14:26:44+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 10 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 10 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_sensitivity": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 3, + "max": 50, + "scale": 1, + "step": 1 + } + }, + "hum_sensitivity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 3, + "max": 10, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 10 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 10 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_alarm": { + "type": "Enum", + "value": { + "range": ["cancel", "loweralarm", "upperalarm"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["cancel", "loweralarm", "upperalarm"] + } + }, + "temp_sensitivity": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 3, + "max": 50, + "scale": 1, + "step": 1 + } + }, + "hum_sensitivity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 3, + "max": 10, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_temperature": 200, + "va_humidity": 59, + "battery_percentage": 8, + "temp_unit_convert": "c", + "maxtemp_set": 600, + "minitemp_set": -100, + "maxhum_set": 70, + "minihum_set": 40, + "temp_alarm": "cancel", + "hum_alarm": "cancel", + "temp_sensitivity": 6, + "hum_sensitivity": 4 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wxnbq_5l1ht8jygsyr1wn1.json b/tests/components/tuya/fixtures/wxnbq_5l1ht8jygsyr1wn1.json new file mode 100644 index 00000000000..9b001a62e19 --- /dev/null +++ b/tests/components/tuya/fixtures/wxnbq_5l1ht8jygsyr1wn1.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Panneaux solaires 2", + "category": "wxnbq", + "product_id": "5l1ht8jygsyr1wn1", + "product_name": "SORIA", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-12T17:57:40+00:00", + "create_time": "2025-08-12T17:57:40+00:00", + "update_time": "2025-08-12T17:57:40+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/xdd_shx9mmadyyeaq88t.json b/tests/components/tuya/fixtures/xdd_shx9mmadyyeaq88t.json new file mode 100644 index 00000000000..99bc7f7b256 --- /dev/null +++ b/tests/components/tuya/fixtures/xdd_shx9mmadyyeaq88t.json @@ -0,0 +1,139 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Plafond bureau ", + "category": "xdd", + "product_id": "shx9mmadyyeaq88t", + "product_name": "Five way ceiling lamp", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-11-10T15:45:03+00:00", + "create_time": "2023-11-10T15:45:03+00:00", + "update_time": "2023-11-10T15:45:03+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "control_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value": 946, + "temp_value": 689, + "colour_data": { + "h": 308, + "s": 381, + "v": 1000 + }, + "countdown": 0, + "do_not_disturb": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/xnyjcn_pb0tc75khaik8qbg.json b/tests/components/tuya/fixtures/xnyjcn_pb0tc75khaik8qbg.json new file mode 100644 index 00000000000..e16ada7974b --- /dev/null +++ b/tests/components/tuya/fixtures/xnyjcn_pb0tc75khaik8qbg.json @@ -0,0 +1,794 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "CBE Pro 2", + "category": "xnyjcn", + "product_id": "pb0tc75khaik8qbg", + "product_name": "CBE Pro", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2025-06-25T11:36:33+00:00", + "create_time": "2025-06-25T11:36:33+00:00", + "update_time": "2025-06-25T11:36:33+00:00", + "function": { + "cell_heating": { + "type": "Boolean", + "value": {} + }, + "expansion_pack_count": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "grid_power": { + "type": "Integer", + "value": { + "unit": "kW", + "min": -100000, + "max": 100000, + "scale": 3, + "step": 1 + } + }, + "main_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack1_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack2_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack3_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack4_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack5_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "pv_power_channel_3": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "pv_power_channel_4": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "offgrid1_export_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "cuml_e_export_offgrid1": { + "type": "Integer", + "value": { + "unit": "Wh", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "backup_reserve": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["self_powered", "time_of_use"] + } + }, + "function_set": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "output_power_limit": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "feedin_power_limit_enable": { + "type": "Boolean", + "value": {} + }, + "feedin_power_limit": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "country_code": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "indicator_light_mode": { + "type": "Enum", + "value": { + "range": ["eco", "on", "off"] + } + }, + "smart_meter_type": { + "type": "Enum", + "value": { + "range": ["none", "ty_lan", "ty_rs485"] + } + }, + "recalibrate_battery": { + "type": "Boolean", + "value": {} + }, + "offgrid1_export_enable": { + "type": "Boolean", + "value": {} + }, + "peak_shaving_enable": { + "type": "Boolean", + "value": {} + }, + "peak_shaving_threshold": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "vibe_light_scene": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "user_schedule": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "offgrid1_mode": { + "type": "Enum", + "value": { + "range": ["backup", "plugin"] + } + }, + "inverter_input_power_limit": { + "type": "Integer", + "value": { + "unit": "kW", + "min": 0, + "max": 2500, + "scale": 3, + "step": 1 + } + }, + "panel_heartbeat": { + "type": "Boolean", + "value": {} + }, + "backup_reserve_recommanded": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "remote_pair": { + "type": "Boolean", + "value": {} + }, + "regulation_grid_export_p_limit": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "inverter_status": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "command_receive": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "command_send": { + "type": "Raw", + "value": { + "maxlen": 128 + } + } + }, + "status_range": { + "serial_number": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "battery_capacity": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 1000000, + "scale": 3, + "step": 1 + } + }, + "error_code": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "connection_state": { + "type": "Enum", + "value": { + "range": ["paired_connected", "unpaired_uncon", "paired_uncon"] + } + }, + "meter_signal_strength": { + "type": "Integer", + "value": { + "unit": "dBm", + "min": 0, + "max": 255, + "scale": 0, + "step": 1 + } + }, + "hardware_version": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "system_version": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cell_heating": { + "type": "Boolean", + "value": {} + }, + "expansion_pack_count": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "pv_power_total": { + "type": "Integer", + "value": { + "unit": "kW", + "min": 0, + "max": 100000, + "scale": 3, + "step": 1 + } + }, + "pv_power_channel_1": { + "type": "Integer", + "value": { + "unit": "kW", + "min": 0, + "max": 100000, + "scale": 3, + "step": 1 + } + }, + "pv_power_channel_2": { + "type": "Integer", + "value": { + "unit": "kW", + "min": 0, + "max": 100000, + "scale": 3, + "step": 1 + } + }, + "current_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "charge_discharge": { + "type": "Enum", + "value": { + "range": ["charging", "discharging", "idle"] + } + }, + "battery_power": { + "type": "Integer", + "value": { + "unit": "kW", + "min": -2500000, + "max": 2500000, + "scale": 3, + "step": 1 + } + }, + "grid_power": { + "type": "Integer", + "value": { + "unit": "kW", + "min": -100000, + "max": 100000, + "scale": 3, + "step": 1 + } + }, + "inverter_output_power": { + "type": "Integer", + "value": { + "unit": "kW", + "min": -20000, + "max": 20000, + "scale": 3, + "step": 1 + } + }, + "main_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack1_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack2_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack3_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack4_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "expansion_pack5_soc": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "pv_power_channel_3": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "pv_power_channel_4": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "offgrid1_export_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "cumulative_energy_generated_pv": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999999, + "scale": 3, + "step": 1 + } + }, + "cumulative_energy_output_inv": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999999, + "scale": 3, + "step": 1 + } + }, + "cumulative_energy_discharged": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999999, + "scale": 3, + "step": 1 + } + }, + "cumulative_energy_charged": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999999, + "scale": 3, + "step": 1 + } + }, + "cumulative_energy_charged_pv": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999999, + "scale": 3, + "step": 1 + } + }, + "cumulative_energy_charged_grid": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999999, + "scale": 3, + "step": 1 + } + }, + "cuml_e_export_offgrid1": { + "type": "Integer", + "value": { + "unit": "Wh", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "backup_reserve": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["self_powered", "time_of_use"] + } + }, + "function_set": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "output_power_limit": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "feedin_power_limit": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "feedin_power_limit_enable": { + "type": "Boolean", + "value": {} + }, + "country_code": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "indicator_light_mode": { + "type": "Enum", + "value": { + "range": ["eco", "on", "off"] + } + }, + "smart_meter_type": { + "type": "Enum", + "value": { + "range": ["none", "ty_lan", "ty_rs485"] + } + }, + "recalibrate_battery": { + "type": "Boolean", + "value": {} + }, + "offgrid1_export_enable": { + "type": "Boolean", + "value": {} + }, + "peak_shaving_enable": { + "type": "Boolean", + "value": {} + }, + "peak_shaving_threshold": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "vibe_light_scene": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "user_schedule": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "offgrid1_mode": { + "type": "Enum", + "value": { + "range": ["backup", "plugin"] + } + }, + "timestamp": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "remote_pair": { + "type": "Boolean", + "value": {} + }, + "inverter_status": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "command_receive": { + "type": "Raw", + "value": { + "maxlen": 128 + } + }, + "command_send": { + "type": "Raw", + "value": { + "maxlen": 128 + } + } + }, + "status": { + "serial_number": "sn1234", + "battery_capacity": 0, + "error_code": "", + "connection_state": "paired_connected", + "meter_signal_strength": 0, + "hardware_version": "", + "system_version": "", + "cell_heating": false, + "expansion_pack_count": 0, + "pv_power_total": 0, + "pv_power_channel_1": 2000, + "pv_power_channel_2": 0, + "current_soc": 43, + "charge_discharge": "discharging", + "battery_power": -2000, + "grid_power": 0, + "inverter_output_power": 2000, + "main_soc": 0, + "expansion_pack1_soc": 0, + "expansion_pack2_soc": 0, + "expansion_pack3_soc": 0, + "expansion_pack4_soc": 0, + "expansion_pack5_soc": 0, + "pv_power_channel_3": 0, + "pv_power_channel_4": 0, + "offgrid1_export_power": 0, + "cumulative_energy_generated_pv": 18565, + "cumulative_energy_output_inv": 13460, + "cumulative_energy_discharged": 8183, + "cumulative_energy_charged": 13288, + "cumulative_energy_charged_pv": 13288, + "cumulative_energy_charged_grid": 0, + "cuml_e_export_offgrid1": 0, + "backup_reserve": 59, + "work_mode": "self_powered", + "function_set": "", + "output_power_limit": 2, + "feedin_power_limit": 0, + "feedin_power_limit_enable": false, + "country_code": "", + "indicator_light_mode": "eco", + "smart_meter_type": "none", + "recalibrate_battery": false, + "offgrid1_export_enable": false, + "peak_shaving_enable": false, + "peak_shaving_threshold": 0, + "vibe_light_scene": "", + "user_schedule": "", + "offgrid1_mode": "backup", + "timestamp": "", + "remote_pair": false, + "inverter_status": "AQELAgADDAED", + "command_receive": "", + "command_send": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json index 92f507abaca..8b46157b0a4 100644 --- a/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json +++ b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json @@ -68,6 +68,16 @@ "type": "Json", "value": {} }, + "power_total": { + "type": "Integer", + "value": { + "unit": "kW", + "min": -99999999, + "max": 999999999, + "scale": 3, + "step": 1 + } + }, "fault": { "type": "Bitmap", "value": { @@ -192,6 +202,7 @@ "power": 6.912, "voltage": 52.7 }, + "power_total": 1500, "fault": 0, "frozen_time_set": { "day": 158, diff --git a/tests/components/tuya/fixtures/znnbq_0kllybtbzftaee7y.json b/tests/components/tuya/fixtures/znnbq_0kllybtbzftaee7y.json new file mode 100644 index 00000000000..3f5bba6e4e6 --- /dev/null +++ b/tests/components/tuya/fixtures/znnbq_0kllybtbzftaee7y.json @@ -0,0 +1,70 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Soria", + "category": "znnbq", + "product_id": "0kllybtbzftaee7y", + "product_name": "Soria", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-12T07:19:59+00:00", + "create_time": "2025-07-12T07:19:59+00:00", + "update_time": "2025-07-12T07:19:59+00:00", + "function": { + "work_mode": { + "type": "Enum", + "value": { + "range": ["power_off", "inverter_power", "grid_power", "battery_power"] + } + } + }, + "status_range": { + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "power_total": { + "type": "Integer", + "value": { + "unit": "kW", + "min": 0, + "max": 900000, + "scale": 3, + "step": 1 + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["power_off", "inverter_power", "grid_power", "battery_power"] + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -40, + "max": 150, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "reverse_energy_total": 10821, + "power_total": 19060, + "work_mode": "power_off", + "temp_current": 28 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zwjcy_gvygg3m8.json b/tests/components/tuya/fixtures/zwjcy_gvygg3m8.json new file mode 100644 index 00000000000..0841e991b29 --- /dev/null +++ b/tests/components/tuya/fixtures/zwjcy_gvygg3m8.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "humid pelargonia", + "category": "zwjcy", + "product_id": "gvygg3m8", + "product_name": "SGS01", + "online": false, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-06-08T09:13:18+00:00", + "create_time": "2025-06-08T09:13:18+00:00", + "update_time": "2025-06-08T09:13:18+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 1400, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "humidity": 100, + "temp_current": 175, + "temp_unit_convert": "c", + "battery_state": "middle", + "battery_percentage": 37 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index ad1838b6755..d0a1d5619ec 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -97,6 +97,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.cat_feeder_feeding-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': None, + 'entity_id': 'binary_sensor.cat_feeder_feeding', + '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': 'Feeding', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feeding', + 'unique_id': 'tuya.igkrtodqg14xvfxlqswwcfeed_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.cat_feeder_feeding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cat Feeder Feeding', + }), + 'context': , + 'entity_id': 'binary_sensor.cat_feeder_feeding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1175,6 +1223,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.siren_charging-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': None, + 'entity_id': 'binary_sensor.siren_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.okwwus27jhqqe2mijbgscharge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.siren_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Siren Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.siren_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.siren_tamper-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.siren_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.okwwus27jhqqe2mijbgstemper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.siren_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Siren Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.siren_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.smart_thermostats_valve-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1566,6 +1712,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.window_downstairs_door-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': None, + 'entity_id': 'binary_sensor.window_downstairs_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.9c1vlsxoscmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.window_downstairs_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Window downstairs Door', + }), + 'context': , + 'entity_id': 'binary_sensor.window_downstairs_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.x5_zigbee_gateway_problem-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr index df6ea532d83..08312702a6d 100644 --- a/tests/components/tuya/snapshots/test_camera.ambr +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -267,3 +267,56 @@ 'state': 'recording', }) # --- +# name: test_platform_setup_and_discovery[camera.mirilla_puerta-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.mirilla_puerta', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.i6xywcsymer1kmb6ps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.mirilla_puerta-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.mirilla_puerta?token=1', + 'friendly_name': 'Mirilla puerta', + 'model_name': 'Smart DoorBell (WiFi)', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.mirilla_puerta', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 7687c68ad31..87304e5e9ad 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -74,6 +74,159 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[climate.anbau-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_modes': list([ + 'off', + ]), + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anbau', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.sq6fbd3pfkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.anbau-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Anbau', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_modes': list([ + 'off', + ]), + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.anbau', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[climate.bathroom_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 70.0, + 'min_temp': 1.0, + 'preset_modes': list([ + 'holiday', + 'eco', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bathroom_radiator', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.fc2ngmpckw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.bathroom_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.5, + 'friendly_name': 'Bathroom radiator', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 70.0, + 'min_temp': 1.0, + 'preset_mode': None, + 'preset_modes': list([ + 'holiday', + 'eco', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 12.0, + }), + 'context': , + 'entity_id': 'climate.bathroom_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- # name: test_platform_setup_and_discovery[climate.boiler_temperature_controller-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -220,6 +373,209 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[climate.el_termostato_de_la_cocina-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.el_termostato_de_la_cocina', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.LmLMc0ht1KW2zYAIkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.el_termostato_de_la_cocina-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 5.5, + 'friendly_name': 'El termostato de la cocina', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.el_termostato_de_la_cocina', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[climate.empore-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_modes': list([ + 'off', + ]), + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.empore', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.paxijfx9fkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.empore-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Empore', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': None, + 'preset_modes': list([ + 'off', + ]), + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 35.0, + }), + 'context': , + 'entity_id': 'climate.empore', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_platform_setup_and_discovery[climate.geti_solar_pv_water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.geti_solar_pv_water_heater', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fcacn8iqbocuow7dsr', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.geti_solar_pv_water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 60.0, + 'friendly_name': 'Geti Solar PV Water Heater', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.geti_solar_pv_water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[climate.kabinet-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -504,6 +860,83 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[climate.salon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 5.9, + 'min_temp': 0.1, + 'preset_modes': list([ + 'holiday', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.salon', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.gm0whbftkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.salon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.3, + 'friendly_name': 'Salon', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 5.9, + 'min_temp': 0.1, + 'preset_mode': None, + 'preset_modes': list([ + 'holiday', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 5.9, + }), + 'context': , + 'entity_id': 'climate.salon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- # name: test_platform_setup_and_discovery[climate.smart_thermostats-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index f18c96596b1..e41c7aa1c29 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -151,6 +151,56 @@ 'state': 'open', }) # --- +# name: test_platform_setup_and_discovery[cover.kit_blinds_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kit_blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.xR2ASpOQgAAqu7Drlccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.kit_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'curtain', + 'friendly_name': 'Kit-Blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kit_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_platform_setup_and_discovery[cover.kitchen_blinds_blind-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -304,6 +354,57 @@ 'state': 'open', }) # --- +# name: test_platform_setup_and_discovery[cover.pergola_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.pergola_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.shga3pmbkwhthvqxgklccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.pergola_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'Pergola Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.pergola_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_platform_setup_and_discovery[cover.persiana_do_quarto_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -355,6 +456,108 @@ 'state': 'open', }) # --- +# name: test_platform_setup_and_discovery[cover.projector_screen_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.projector_screen_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.aiag5pku0x39rkfllccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.projector_screen_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'Projector Screen Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.projector_screen_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.roller_shutter_living_room_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.roller_shutter_living_room_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.jzpap0inhkykqtlwgklccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.roller_shutter_living_room_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 75, + 'device_class': 'curtain', + 'friendly_name': 'Roller shutter Living Room Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.roller_shutter_living_room_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_platform_setup_and_discovery[cover.tapparelle_studio_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index f2b615ec269..5330e5ca729 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -1,4 +1,54 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[fan.arida_stavern-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.arida_stavern', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.8u5ftxkt52smougesc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.arida_stavern-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arida Stavern ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.arida_stavern', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -450,6 +500,63 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[fan.living_room_dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_dehumidifier', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.g1qorlffoy2iyo9bsc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.living_room_dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room dehumidifier', + 'percentage': 100, + 'percentage_step': 50.0, + 'preset_mode': None, + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.living_room_dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[fan.tower_fan_ca_407g_smart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index 46535810d7d..f240c4b130d 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -1,4 +1,60 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[humidifier.arida_stavern-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.arida_stavern', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.8u5ftxkt52smougescswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.arida_stavern-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 61, + 'device_class': 'dehumidifier', + 'friendly_name': 'Arida Stavern ', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.arida_stavern', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[humidifier.dehumidifer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -111,3 +167,115 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[humidifier.klarta_humea-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.klarta_humea', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.btpss2f6kwfi294rqsjswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.klarta_humea-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidifier', + 'friendly_name': 'KLARTA HUMEA', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.klarta_humea', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.living_room_dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 80, + 'min_humidity': 25, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.living_room_dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.g1qorlffoy2iyo9bscswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.living_room_dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 48, + 'device_class': 'dehumidifier', + 'friendly_name': 'Living room dehumidifier', + 'humidity': 47, + 'max_humidity': 80, + 'min_humidity': 25, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.living_room_dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index a70d38c6fbc..67ca9ddec1a 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -1,4 +1,35 @@ # serializer version: 1 +# name: test_device_registry[1nw1rysgyj8th1l5qbnxw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '1nw1rysgyj8th1l5qbnxw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SORIA (unsupported)', + 'model_id': '5l1ht8jygsyr1wn1', + 'name': 'Panneaux solaires 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[2k8wyjo7iidkohuczc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -278,6 +309,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[4bxfp3kgncpcgx5uycjzs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '4bxfp3kgncpcgx5uycjzs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'YINMIK Water Quality Tester', + 'model_id': 'u5xgcpcngk3pfxb4', + 'name': 'YINMIK Water Quality Tester', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[4fO1qIzYbcdMUHqAjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -712,6 +774,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[6pd3bkidqld] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '6pd3bkidqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Metering_3PN_ZB', + 'model_id': 'dikb3dp6', + 'name': 'Medidor de Energia', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[6tbtkuv3tal1aesfjxq] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -991,6 +1084,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[8m3ggyvgycjwz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '8m3ggyvgycjwz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SGS01', + 'model_id': 'gvygg3m8', + 'name': 'humid pelargonia', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[8u5ftxkt52smougesc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '8u5ftxkt52smougesc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Arida Stavern ', + 'model_id': 'eguoms25tkxtf5u8', + 'name': 'Arida Stavern ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[97k3pwirjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1022,6 +1177,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[9AzrW5XtELTySJxqzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '9AzrW5XtELTySJxqzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mini Smart Plug', + 'model_id': 'qxJSyTLEtX5WrzA9', + 'name': 'LivR', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[9c1vlsxoscm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '9c1vlsxoscm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Contact Sensor', + 'model_id': 'oxslv1c9', + 'name': 'Window downstairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[9oh1h1uyalfykgg4bdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1177,6 +1394,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[JLWRUpPiwMTwKXtTtq] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'JLWRUpPiwMTwKXtTtq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch (unsupported)', + 'model_id': 'TtXKwTMwiPpURWLJ', + 'name': 'Dining-Blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[LJ9zTFQTfMgsG2Ahzc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1239,6 +1487,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[LmLMc0ht1KW2zYAIkw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'LmLMc0ht1KW2zYAIkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'thermostat', + 'model_id': 'IAYz2WK1th0cMLmL', + 'name': 'El termostato de la cocina', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[NVjuXIQ6QH9eZLHCzc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1487,6 +1766,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[ai9swgb6tyinbwbxjxq] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ai9swgb6tyinbwbxjxq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SWS 16600 WiFi SH', + 'model_id': 'xbwbniyt6bgws9ia', + 'name': 'SWS 16600 WiFi SH', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[aiag5pku0x39rkfllc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'aiag5pku0x39rkfllc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'VIVIDSTORM SCREEN', + 'model_id': 'lfkr93x0ukp5gaia', + 'name': 'Projector Screen', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[aje5kxgmhhxdihqizc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1549,6 +1890,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[ao3z3oeyvepe8o3xqdt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ao3z3oeyvepe8o3xqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T & H Sensor (unsupported)', + 'model_id': 'x3o8epevyeo3z3oa', + 'name': 'Interior Bedroom Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[aoyweq8xbx7qfndijd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1787,7 +2159,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'BlissRadia (unsupported)', + 'model': 'BlissRadia ', 'model_id': 'ssimhf6r8kgwepfb', 'name': 'BlissRadia ', 'name_by_user': None, @@ -1828,6 +2200,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[bjum5isf7h6xpbrvzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'bjum5isf7h6xpbrvzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '接HA双向计量插座', + 'model_id': 'vrbpx6h7fsi5mujb', + 'name': '接HA双向计量插座', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[bl5cuqxnqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1859,6 +2262,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[btpss2f6kwfi294rqsj] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'btpss2f6kwfi294rqsj', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'KLARTA HUMEA', + 'model_id': 'r492ifwk6f2ssptb', + 'name': 'KLARTA HUMEA', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[btyk53n3v10z7a97zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'btyk53n3v10z7a97zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Double Digital Meter (unsupported)', + 'model_id': '79a7z01v3n35kytb', + 'name': 'Double Digital Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[buzituffc13pgb1jjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1952,6 +2417,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[cd6bezcadvjngj5jrip] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'cd6bezcadvjngj5jrip', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'QNECT WI-FI PIR SENSOR (unsupported)', + 'model_id': 'j5jgnjvdaczeb6dc', + 'name': 'QNECT WI-FI PIR SENSOR', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[cijerqyssiwrf7deqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2107,6 +2603,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[cvowstbid97lokayjb2oc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'cvowstbid97lokayjb2oc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'PTH-9CW(QC)', + 'model_id': 'yakol79dibtswovc', + 'name': 'PTH-9CW 32', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[cwwk68dyfsh2eqi4jbqr] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2138,6 +2665,99 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[dBFBdywk9gTihUQmzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'dBFBdywk9gTihUQmzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket', + 'model_id': 'mQUhiTg9kwydBFBd', + 'name': 'Waschmaschine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dNBnmtjLU8eRWHf0zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'dNBnmtjLU8eRWHf0zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SP22-10A', + 'model_id': '0fHWRe8ULjtmnBNd', + 'name': 'Weihnachten3', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dj8foneugkjc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'dj8foneugkjc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Switch (unsupported)', + 'model_id': 'uenof8jd', + 'name': 'Smart Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[dke76hazlc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2324,6 +2944,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[f4vvhmhvseuiqs6pqdt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'f4vvhmhvseuiqs6pqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Contact Sensor (unsupported)', + 'model_id': 'p6sqiuesvhmhvv4f', + 'name': 'Entrance Door', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[fasvixqysw1lxvjprd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2386,6 +3037,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[fc2ngmpckw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'fc2ngmpckw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SmartTRV', + 'model_id': 'cpmgn2cf', + 'name': 'Bathroom radiator', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fcacn8iqbocuow7dsr] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'fcacn8iqbocuow7dsr', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Geti Solar PV Water Heater', + 'model_id': 'd7woucobqi8ncacf', + 'name': 'Geti Solar PV Water Heater', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[fcdadqsiax2gvnt0qld] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2603,6 +3316,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[g1qorlffoy2iyo9bsc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'g1qorlffoy2iyo9bsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Dehumidifier ', + 'model_id': 'b9oyi2yofflroq1g', + 'name': 'Living room dehumidifier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[g5uso5ajgkxw] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2665,6 +3409,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[g9h9sblxpb5wdwzkqkynw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'g9h9sblxpb5wdwzkqkynw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart IR (unsupported)', + 'model_id': 'kzwdw5bpxlbs9h9g', + 'name': 'IR Minero', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gbq8kiahk57ct0bpncjynx] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gbq8kiahk57ct0bpncjynx', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'CBE Pro', + 'model_id': 'pb0tc75khaik8qbg', + 'name': 'CBE Pro 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[ggimpv4dqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2851,6 +3657,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[gm0whbftkw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gm0whbftkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ZX-5442', + 'model_id': 'tfbhw0mg', + 'name': 'Salon', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gnZOKztbAtcBkEGPzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gnZOKztbAtcBkEGPzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'PGEkBctAbtzKOZng', + 'name': 'Din', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[gnqwzcph94wj2sl5nq] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3099,6 +3967,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[i6xywcsymer1kmb6ps] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'i6xywcsymer1kmb6ps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart DoorBell (WiFi)', + 'model_id': '6bmk1remyscwyx6i', + 'name': 'Mirilla puerta', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[iaagy4qigcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3192,6 +4091,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[igkrtodqg14xvfxlqswwc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'igkrtodqg14xvfxlqswwc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Wi-Fi Pet Feeder', + 'model_id': 'lxfvx41gqdotrkgi', + 'name': 'Cat Feeder', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[ijne16zv8vpqmubnjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3595,6 +4525,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[jzpap0inhkykqtlwgklc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'jzpap0inhkykqtlwgklc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch', + 'model_id': 'wltqkykhni0papzj', + 'name': 'Roller shutter Living Room', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[kcdngswaxs8hm52bnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3998,6 +4959,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[llw1rhcau4y3othdzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'llw1rhcau4y3othdzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Double Digital Meter (unsupported)', + 'model_id': 'dhto3y4uachr1wll', + 'name': 'Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[mgcpxpmovasazerdps] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4308,6 +5300,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[o5kqedcacfng0plpnocfw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'o5kqedcacfng0plpnocfw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Zigbee Gateway (unsupported)', + 'model_id': 'plp0gnfcacdeqk5o', + 'name': 'Zigbee Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[o71einxvuuktuljcjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4618,6 +5641,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[paxijfx9fkw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'paxijfx9fkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Radiator Thermostat Controller', + 'model_id': '9xfjixap', + 'name': 'Empore', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[pdasfna8fswh4a0tzc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5166,7 +6220,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Smart White Noise Machine (unsupported)', + 'model': 'Smart White Noise Machine', 'model_id': '45idzfufidgee7ir', 'name': 'Smart White Noise Machine', 'name_by_user': None, @@ -5269,6 +6323,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[rvsneuipzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'rvsneuipzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Signal repeater', + 'model_id': 'piuensvr', + 'name': 'Signal repeater', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[rwp6kdezm97s2nktzc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5393,6 +6478,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[shga3pmbkwhthvqxgklc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'shga3pmbkwhthvqxgklc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch', + 'model_id': 'xqvhthwkbmp3aghs', + 'name': 'Pergola', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[sifg4pfqsylsayg0jd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5486,6 +6602,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[sq6fbd3pfkw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'sq6fbd3pfkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Radiator Thermostat', + 'model_id': 'p3dbf6qs', + 'name': 'Anbau', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[srp7cfjtn6sshwmt2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5672,6 +6819,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[t88qaeyydamm9xhsddx] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 't88qaeyydamm9xhsddx', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Five way ceiling lamp', + 'model_id': 'shx9mmadyyeaq88t', + 'name': 'Plafond bureau ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[tcdk0skzcpisexj2zc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5858,6 +7036,68 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[uYmmlWz6zs0dIgYDjbgs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'uYmmlWz6zs0dIgYDjbgs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Siren (unsupported)', + 'model_id': 'DYgId0sz6zWlmmYu', + 'name': 'Siren', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[uc9fL2NpR79iCzGIzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'uc9fL2NpR79iCzGIzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket', + 'model_id': 'IGzCi97RpN2Lf9cu', + 'name': 'N4-Auto', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[uew54dymycjwz] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6013,6 +7253,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[ve3ctzrqgcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 've3ctzrqgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Temperature and humidity sensor', + 'model_id': 'qrztc3ev', + 'name': 'Temperature and humidity sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[vnj3sa6mqahro6phjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6044,6 +7315,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[vpfdskpi8pr8cbtfzjs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'vpfdskpi8pr8cbtfzjs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'geniodesk', + 'model_id': 'ftbc8rp8ipksdfpv', + 'name': 'mesa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[vrhdtr5fawoiyth9qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6292,6 +7594,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[xR2ASpOQgAAqu7Drlc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'xR2ASpOQgAAqu7Drlc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Wi-Fi Curtian Switch', + 'model_id': 'rD7uqAAgQOpSA2Rx', + 'name': 'Kit-Blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[xenxir4a0tn0p1qcqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6323,6 +7656,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[y7eeatfzbtbyllk0qbnnz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'y7eeatfzbtbyllk0qbnnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Soria', + 'model_id': '0kllybtbzftaee7y', + 'name': 'Soria', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[yky6kunazmaitupzjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6385,6 +7749,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[yohkwjjdjlzludd3psm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'yohkwjjdjlzludd3psm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ZEDAR K1200', + 'model_id': '3ddulzljdjjwkhoy', + 'name': 'Kattenbak', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[yuanswy6scm] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6416,6 +7811,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[yybgnzr3ztws] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'yybgnzr3ztws', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Cooking Thermometer', + 'model_id': '3rzngbyy', + 'name': 'Grillhőmérő', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[z7cu5t8bl9tt9fabjd] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index c04cee4a46d..b50bb1804be 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -345,6 +345,67 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.blissradia-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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.blissradia', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfpewgk8r6fhmissdyzbswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.blissradia-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'BlissRadia ', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.blissradia', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.cam_garage_indicator_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2347,6 +2408,146 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.pergola_backlight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.pergola_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.shga3pmbkwhthvqxgklcswitch_backlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.pergola_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Pergola Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pergola_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.plafond_bureau-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + '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.plafond_bureau', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.t88qaeyydamm9xhsddxswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.plafond_bureau-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 241, + 'color_mode': , + 'color_temp': 260, + 'color_temp_kelvin': 3832, + 'friendly_name': 'Plafond bureau ', + 'hs_color': tuple( + 26.903, + 38.001, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 202, + 158, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.43, + 0.368, + ), + }), + 'context': , + 'entity_id': 'light.plafond_bureau', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[light.pokerlamp_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2609,6 +2810,63 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[light.roller_shutter_living_room_backlight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.roller_shutter_living_room_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.jzpap0inhkykqtlwgklcswitch_backlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.roller_shutter_living_room_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Roller shutter Living Room Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.roller_shutter_living_room_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.sjiethoes-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2817,6 +3075,77 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.smart_white_noise_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + '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.smart_white_noise_machine', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ri7eegdifufzdi54dyzbswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.smart_white_noise_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 1003, + 'color_mode': , + 'friendly_name': 'Smart White Noise Machine', + 'hs_color': tuple( + 239.666, + 393.307, + ), + 'rgb_color': tuple( + -748, + -742, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + -0.03, + -0.215, + ), + }), + 'context': , + 'entity_id': 'light.smart_white_noise_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[light.solar_zijpad-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index bc49b03cd36..15003c65db0 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -58,6 +58,64 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[number.blissradia_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 5.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.blissradia_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bfpewgk8r6fhmissdyzbvolume_set', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.blissradia_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BlissRadia Volume', + 'max': 100.0, + 'min': 5.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.blissradia_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- # name: test_platform_setup_and_discovery[number.boiler_temperature_controller_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -174,6 +232,239 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[number.cat_feeder_feed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 50.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.cat_feeder_feed', + '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': 'Feed', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feed', + 'unique_id': 'tuya.igkrtodqg14xvfxlqswwcmanual_feed', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.cat_feeder_feed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cat Feeder Feed', + 'max': 50.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.cat_feeder_feed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.cat_feeder_voice_times-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.cat_feeder_voice_times', + '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': 'Voice times', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voice_times', + 'unique_id': 'tuya.igkrtodqg14xvfxlqswwcvoice_times', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.cat_feeder_voice_times-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cat Feeder Voice times', + 'max': 5.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.cat_feeder_voice_times', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.cbe_pro_2_battery_backup_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.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.cbe_pro_2_battery_backup_reserve', + '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': 'Battery backup reserve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_backup_reserve', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxbackup_reserve', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.cbe_pro_2_battery_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CBE Pro 2 Battery backup reserve', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.cbe_pro_2_battery_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.cbe_pro_2_inverter_output_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100000.0, + 'min': 0.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.cbe_pro_2_inverter_output_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inverter output power limit', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_output_power_limit', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxoutput_power_limit', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[number.cbe_pro_2_inverter_output_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CBE Pro 2 Inverter output power limit', + 'max': 100000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'number.cbe_pro_2_inverter_output_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- # name: test_platform_setup_and_discovery[number.cleverio_pf100_feed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -232,6 +523,122 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[number.grillhomero_cooking_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 300.0, + 'min': -30.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.grillhomero_cooking_temperature', + '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': 'Cooking temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_temperature', + 'unique_id': 'tuya.yybgnzr3ztwscook_temperature', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[number.grillhomero_cooking_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Grillhőmérő Cooking temperature', + 'max': 300.0, + 'min': -30.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.grillhomero_cooking_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[number.grillhomero_cooking_temperature_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 300.0, + 'min': -30.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.grillhomero_cooking_temperature_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': 'Cooking temperature 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_cook_temperature', + 'unique_id': 'tuya.yybgnzr3ztwscook_temperature_2', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[number.grillhomero_cooking_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Grillhőmérő Cooking temperature 2', + 'max': 300.0, + 'min': -30.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.grillhomero_cooking_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[number.hot_water_heat_pump_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1231,6 +1638,64 @@ 'state': '-2.0', }) # --- +# name: test_platform_setup_and_discovery[number.mirilla_puerta_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 1.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.mirilla_puerta_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.i6xywcsymer1kmb6psbasic_device_volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.mirilla_puerta_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mirilla puerta Volume', + 'max': 100.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mirilla_puerta_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- # name: test_platform_setup_and_discovery[number.multifunction_alarm_alarm_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1816,7 +2281,65 @@ 'state': '-2.0', }) # --- -# name: test_platform_setup_and_discovery[number.sous_vide_cook_temperature-entry] +# name: test_platform_setup_and_discovery[number.smart_white_noise_machine_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.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.smart_white_noise_machine_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.ri7eegdifufzdi54dyzbvolume_set', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.smart_white_noise_machine_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart White Noise Machine Volume', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.smart_white_noise_machine_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cooking_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1834,7 +2357,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.sous_vide_cook_temperature', + 'entity_id': 'number.sous_vide_cooking_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1846,7 +2369,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cook temperature', + 'original_name': 'Cooking temperature', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1856,10 +2379,10 @@ 'unit_of_measurement': '℃', }) # --- -# name: test_platform_setup_and_discovery[number.sous_vide_cook_temperature-state] +# name: test_platform_setup_and_discovery[number.sous_vide_cooking_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sous Vide Cook temperature', + 'friendly_name': 'Sous Vide Cooking temperature', 'max': 92.5, 'min': 25.0, 'mode': , @@ -1867,14 +2390,14 @@ 'unit_of_measurement': '℃', }), 'context': , - 'entity_id': 'number.sous_vide_cook_temperature', + 'entity_id': 'number.sous_vide_cooking_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[number.sous_vide_cook_time-entry] +# name: test_platform_setup_and_discovery[number.sous_vide_cooking_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1892,7 +2415,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.sous_vide_cook_time', + 'entity_id': 'number.sous_vide_cooking_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1904,7 +2427,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cook time', + 'original_name': 'Cooking time', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1914,10 +2437,10 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[number.sous_vide_cook_time-state] +# name: test_platform_setup_and_discovery[number.sous_vide_cooking_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sous Vide Cook time', + 'friendly_name': 'Sous Vide Cooking time', 'max': 5999.0, 'min': 1.0, 'mode': , @@ -1925,7 +2448,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.sous_vide_cook_time', + 'entity_id': 'number.sous_vide_cooking_time', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 7c68a647040..069b9199f0b 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -293,7 +293,123 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'low', + 'state': 'mute', + }) +# --- +# name: test_platform_setup_and_discovery[select.arida_stavern_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.arida_stavern_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.8u5ftxkt52smougesccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.arida_stavern_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arida Stavern Countdown', + 'options': list([ + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.arida_stavern_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.arida_stavern_target_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '40', + '50', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.arida_stavern_target_humidity', + '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': 'Target humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_humidity', + 'unique_id': 'tuya.8u5ftxkt52smougescdehumidify_set_enum', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.arida_stavern_target_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arida Stavern Target humidity', + 'options': list([ + '40', + '50', + ]), + }), + 'context': , + 'entity_id': 'select.arida_stavern_target_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_platform_setup_and_discovery[select.aubess_cooker_indicator_light_mode-entry] @@ -1702,6 +1818,63 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[select.cbe_pro_2_inverter_work_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'self_powered', + 'time_of_use', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cbe_pro_2_inverter_work_mode', + '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': 'Inverter work mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_work_mode', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxwork_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cbe_pro_2_inverter_work_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CBE Pro 2 Inverter work mode', + 'options': list([ + 'self_powered', + 'time_of_use', + ]), + }), + 'context': , + 'entity_id': 'select.cbe_pro_2_inverter_work_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'self_powered', + }) +# --- # name: test_platform_setup_and_discovery[select.ceiling_fan_with_light_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2721,6 +2894,124 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[select.jie_hashuang_xiang_ji_liang_cha_zuo_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.jie_hashuang_xiang_ji_liang_cha_zuo_indicator_light_mode', + '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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.jie_hashuang_xiang_ji_liang_cha_zuo_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '接HA双向计量插座 Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.jie_hashuang_xiang_ji_liang_cha_zuo_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.jie_hashuang_xiang_ji_liang_cha_zuo_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.jie_hashuang_xiang_ji_liang_cha_zuo_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.jie_hashuang_xiang_ji_liang_cha_zuo_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '接HA双向计量插座 Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.jie_hashuang_xiang_ji_liang_cha_zuo_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_off', + }) +# --- # name: test_platform_setup_and_discovery[select.kalado_air_purifier_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2843,6 +3134,77 @@ 'state': 'forward', }) # --- +# name: test_platform_setup_and_discovery[select.klarta_humea_spraying_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.klarta_humea_spraying_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': 'Spraying level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidifier_level', + 'unique_id': 'tuya.btpss2f6kwfi294rqsjlevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.klarta_humea_spraying_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'KLARTA HUMEA Spraying level', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + ]), + }), + 'context': , + 'entity_id': 'select.klarta_humea_spraying_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[select.lave_linge_indicator_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2961,6 +3323,303 @@ 'state': 'power_on', }) # --- +# name: test_platform_setup_and_discovery[select.living_room_dehumidifier_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.living_room_dehumidifier_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.g1qorlffoy2iyo9bsccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.living_room_dehumidifier_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room dehumidifier Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.living_room_dehumidifier_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[select.mesa_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mesa_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': 'Level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'desk_level', + 'unique_id': 'tuya.vpfdskpi8pr8cbtfzjslevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.mesa_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mesa Level', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + ]), + }), + 'context': , + 'entity_id': 'select.mesa_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_1', + }) +# --- +# name: test_platform_setup_and_discovery[select.mesa_up_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'up', + 'down', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mesa_up_down', + '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': 'Up/Down', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'desk_up_down', + 'unique_id': 'tuya.vpfdskpi8pr8cbtfzjsup_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.mesa_up_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mesa Up/Down', + 'options': list([ + 'up', + 'down', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'select.mesa_up_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_platform_setup_and_discovery[select.mirilla_puerta_anti_flicker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mirilla_puerta_anti_flicker', + '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': 'Anti-flicker', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_anti_flicker', + 'unique_id': 'tuya.i6xywcsymer1kmb6psbasic_anti_flicker', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.mirilla_puerta_anti_flicker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mirilla puerta Anti-flicker', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.mirilla_puerta_anti_flicker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.mirilla_puerta_ipc_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mirilla_puerta_ipc_mode', + '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': 'IPC mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ipc_work_mode', + 'unique_id': 'tuya.i6xywcsymer1kmb6psipc_work_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.mirilla_puerta_ipc_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mirilla puerta IPC mode', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.mirilla_puerta_ipc_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[select.office_indicator_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3136,6 +3795,63 @@ 'state': 'back', }) # --- +# name: test_platform_setup_and_discovery[select.projector_screen_motor_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'forward', + 'back', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.projector_screen_motor_mode', + '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': 'Motor mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_motor_mode', + 'unique_id': 'tuya.aiag5pku0x39rkfllccontrol_back_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.projector_screen_motor_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Projector Screen Motor mode', + 'options': list([ + 'forward', + 'back', + ]), + }), + 'context': , + 'entity_id': 'select.projector_screen_motor_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'forward', + }) +# --- # name: test_platform_setup_and_discovery[select.raspy4_home_assistant_indicator_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3981,7 +4697,7 @@ 'state': 'level_5', }) # --- -# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_a_level-entry] +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4007,7 +4723,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': None, - 'entity_id': 'select.sunbeam_bedding_side_a_level', + 'entity_id': 'select.sunbeam_bedding_level_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4019,20 +4735,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:thermometer-lines', - 'original_name': 'Side A Level', + 'original_name': 'Level 1', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'blanket_level', + 'translation_key': 'indexed_blanket_level', 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel_1', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_a_level-state] +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sunbeam Bedding Side A Level', + 'friendly_name': 'Sunbeam Bedding Level 1', 'icon': 'mdi:thermometer-lines', 'options': list([ 'level_1', @@ -4048,14 +4764,14 @@ ]), }), 'context': , - 'entity_id': 'select.sunbeam_bedding_side_a_level', + 'entity_id': 'select.sunbeam_bedding_level_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'level_5', }) # --- -# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_b_level-entry] +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4081,7 +4797,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': None, - 'entity_id': 'select.sunbeam_bedding_side_b_level', + 'entity_id': 'select.sunbeam_bedding_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4093,20 +4809,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:thermometer-lines', - 'original_name': 'Side B Level', + 'original_name': 'Level 2', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'blanket_level', + 'translation_key': 'indexed_blanket_level', 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel_2', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_b_level-state] +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sunbeam Bedding Side B Level', + 'friendly_name': 'Sunbeam Bedding Level 2', 'icon': 'mdi:thermometer-lines', 'options': list([ 'level_1', @@ -4122,7 +4838,7 @@ ]), }), 'context': , - 'entity_id': 'select.sunbeam_bedding_side_b_level', + 'entity_id': 'select.sunbeam_bedding_level_2', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 6c11d6034b8..6d20cc5c03d 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -114,6 +114,57 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_total_energy-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.3dprinter_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.pykascx9yfqrxtbgzcadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '3DPrinter Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.3dprinter_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- # name: test_platform_setup_and_discovery[sensor.3dprinter_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -288,6 +339,62 @@ 'state': '11374.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.6294ha_total_energy-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.6294ha_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': '6294HA Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.6294ha_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.201', + }) +# --- # name: test_platform_setup_and_discovery[sensor.6294ha_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -400,6 +507,110 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.aqi_battery_state-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.aqi_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Battery state', + }), + 'context': , + 'entity_id': 'sensor.aqi_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_carbon_dioxide-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.aqi_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'ppm', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_dioxide', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2occo2_value', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'AQI Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.aqi_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '419.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aqi_formaldehyde-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -449,7 +660,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': '0.006', }) # --- # name: test_platform_setup_and_discovery[sensor.aqi_humidity-entry] @@ -502,7 +713,116 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '53.0', + 'state': '54.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_pm10-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.aqi_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm10', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocpm10', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'AQI PM10', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.aqi_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_pm2_5-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.aqi_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'μg/m³', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocpm25_value', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'AQI PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.aqi_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', }) # --- # name: test_platform_setup_and_discovery[sensor.aqi_temperature-entry] @@ -558,7 +878,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '26.0', + 'state': '24.0', }) # --- # name: test_platform_setup_and_discovery[sensor.aqi_volatile_organic_compounds-entry] @@ -611,7 +931,116 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.018', + 'state': '0.071', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.arida_stavern_humidity-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.arida_stavern_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.8u5ftxkt52smougeschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.arida_stavern_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Arida Stavern Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.arida_stavern_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.arida_stavern_temperature-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.arida_stavern_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.8u5ftxkt52smougesctemp_indoor', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.arida_stavern_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Arida Stavern Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.arida_stavern_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', }) # --- # name: test_platform_setup_and_discovery[sensor.aubess_cooker_current-entry] @@ -729,6 +1158,57 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_total_energy-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.aubess_cooker_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.cju47ovcbeuapei2zcadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Cooker Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aubess_cooker_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aubess_cooker_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -903,6 +1383,57 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_total_energy-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.aubess_washing_machine_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizcadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Washing Machine Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aubess_washing_machine_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1401,6 +1932,62 @@ 'state': '21.7', }) # --- +# name: test_platform_setup_and_discovery[sensor.bassin_total_energy-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.bassin_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Bassin Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bassin_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.021', + }) +# --- # name: test_platform_setup_and_discovery[sensor.bassin_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1723,6 +2310,174 @@ 'state': 'high', }) # --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_dew_point-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.br_7_in_1_wlan_wetterstation_anthrazit_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point_temperature', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqdew_point_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_feels_like-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.br_7_in_1_wlan_wetterstation_anthrazit_feels_like', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feels like', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feels_like_temperature', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqfeellike_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_feels_like-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Feels like', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_feels_like', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-65.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_heat_index-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.br_7_in_1_wlan_wetterstation_anthrazit_heat_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heat index', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heat_index_temperature', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqheat_index', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_heat_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Heat index', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_heat_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2485,6 +3240,62 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_chill_index-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.br_7_in_1_wlan_wetterstation_anthrazit_wind_chill_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind chill index', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_chill_index_temperature', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqwindchill_index', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_chill_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Wind chill index', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_chill_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-65.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_direction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2574,7 +3385,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed', + 'translation_key': None, 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqwindspeed_avg', 'unit_of_measurement': , }) @@ -2648,6 +3459,671 @@ 'state': '80.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.cat_feeder_last_amount-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.cat_feeder_last_amount', + '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': 'Last amount', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_amount', + 'unique_id': 'tuya.igkrtodqg14xvfxlqswwcfeed_report', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cat_feeder_last_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cat Feeder Last amount', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.cat_feeder_last_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_battery_power-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.cbe_pro_2_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxbattery_power', + 'unit_of_measurement': 'kW', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CBE Pro 2 Battery power', + 'state_class': , + 'unit_of_measurement': 'kW', + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_battery_soc-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': , + 'entity_id': 'sensor.cbe_pro_2_battery_soc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery SOC', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_soc', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxcurrent_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_battery_soc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'CBE Pro 2 Battery SOC', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_battery_soc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_inverter_output_power-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.cbe_pro_2_inverter_output_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inverter output power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_output_power', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxinverter_output_power', + 'unit_of_measurement': 'kW', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_inverter_output_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CBE Pro 2 Inverter output power', + 'state_class': , + 'unit_of_measurement': 'kW', + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_inverter_output_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_battery_charge_energy-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.cbe_pro_2_lifetime_battery_charge_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime battery charge energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_battery_charge_energy', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxcumulative_energy_charged', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_battery_charge_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CBE Pro 2 Lifetime battery charge energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_lifetime_battery_charge_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.288', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_battery_discharge_energy-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.cbe_pro_2_lifetime_battery_discharge_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime battery discharge energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_battery_discharge_energy', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxcumulative_energy_discharged', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_battery_discharge_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CBE Pro 2 Lifetime battery discharge energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_lifetime_battery_discharge_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.183', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_inverter_output_energy-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.cbe_pro_2_lifetime_inverter_output_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime inverter output energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_inverter_output_energy', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxcumulative_energy_output_inv', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_inverter_output_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CBE Pro 2 Lifetime inverter output energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_lifetime_inverter_output_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.46', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_off_grid_port_energy-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.cbe_pro_2_lifetime_off_grid_port_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime off-grid port energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_offgrid_port_energy', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxcuml_e_export_offgrid1', + 'unit_of_measurement': 'Wh', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_off_grid_port_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CBE Pro 2 Lifetime off-grid port energy', + 'state_class': , + 'unit_of_measurement': 'Wh', + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_lifetime_off_grid_port_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_pv_energy-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.cbe_pro_2_lifetime_pv_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime PV energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_pv_energy', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxcumulative_energy_generated_pv', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_lifetime_pv_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'CBE Pro 2 Lifetime PV energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_lifetime_pv_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.565', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_pv_channel_1_power-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.cbe_pro_2_pv_channel_1_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV channel 1 power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pv_channel_power', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxpv_power_channel_1', + 'unit_of_measurement': 'kW', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_pv_channel_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CBE Pro 2 PV channel 1 power', + 'state_class': , + 'unit_of_measurement': 'kW', + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_pv_channel_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_pv_channel_2_power-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.cbe_pro_2_pv_channel_2_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV channel 2 power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pv_channel_power', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxpv_power_channel_2', + 'unit_of_measurement': 'kW', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_pv_channel_2_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CBE Pro 2 PV channel 2 power', + 'state_class': , + 'unit_of_measurement': 'kW', + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_pv_channel_2_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_total_pv_power-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.cbe_pro_2_total_pv_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total PV power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_pv_power', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxpv_power_total', + 'unit_of_measurement': 'kW', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cbe_pro_2_total_pv_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'CBE Pro 2 Total PV power', + 'state_class': , + 'unit_of_measurement': 'kW', + }), + 'context': , + 'entity_id': 'sensor.cbe_pro_2_total_pv_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.cleverio_pf100_last_amount-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2815,6 +4291,62 @@ 'state': '425.8', }) # --- +# name: test_platform_setup_and_discovery[sensor.consommation_total_energy-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.consommation_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Consommation Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.consommation_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.consommation_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3148,6 +4680,58 @@ 'state': '593.5', }) # --- +# name: test_platform_setup_and_discovery[sensor.droger_total_energy-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.droger_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzcadd_ele', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'droger Total energy', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.droger_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.droger_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3431,6 +5015,62 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_total_energy-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.duan_lu_qi_ha_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': '断路器HA Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- # name: test_platform_setup_and_discovery[sensor.eau_chaude_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3546,6 +5186,58 @@ 'state': '2441.7', }) # --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_total_energy-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.eau_chaude_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldadd_ele', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eau Chaude Total energy', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.eau_chaude_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.39', + }) +# --- # name: test_platform_setup_and_discovery[sensor.eau_chaude_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4280,6 +5972,57 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_total_energy-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.elivco_kitchen_socket_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzcadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco Kitchen Socket Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.elivco_kitchen_socket_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.024', + }) +# --- # name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4454,6 +6197,57 @@ 'state': '10.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_total_energy-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.elivco_tv_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzcadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco TV Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.elivco_tv_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.006', + }) +# --- # name: test_platform_setup_and_discovery[sensor.elivco_tv_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4681,6 +6475,57 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.framboisier_total_energy-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.framboisier_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.51tdkcsamisw9ukycpadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisier Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.framboisier_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- # name: test_platform_setup_and_discovery[sensor.framboisier_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5121,6 +6966,62 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_total_energy-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.garage_socket_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.3d4yosotwk27nqxvzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Garage Socket Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garage_socket_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- # name: test_platform_setup_and_discovery[sensor.garage_socket_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5668,6 +7569,171 @@ 'state': '32.2', }) # --- +# name: test_platform_setup_and_discovery[sensor.grillhomero_battery-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': , + 'entity_id': 'sensor.grillhomero_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.yybgnzr3ztwsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.grillhomero_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Grillhőmérő Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.grillhomero_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.grillhomero_temperature-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.grillhomero_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.yybgnzr3ztwstemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.grillhomero_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Grillhőmérő Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.grillhomero_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.grillhomero_temperature_2-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.grillhomero_temperature_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature', + 'unique_id': 'tuya.yybgnzr3ztwstemp_current_2', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.grillhomero_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Grillhőmérő Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.grillhomero_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.hl400_pm2_5-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5776,7 +7842,7 @@ 'state': '42.0', }) # --- -# name: test_platform_setup_and_discovery[sensor.house_water_level_distance-entry] +# name: test_platform_setup_and_discovery[sensor.house_water_level_depth-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5791,7 +7857,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.house_water_level_distance', + 'entity_id': 'sensor.house_water_level_depth', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5806,7 +7872,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Distance', + 'original_name': 'Depth', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, @@ -5816,16 +7882,16 @@ 'unit_of_measurement': 'm', }) # --- -# name: test_platform_setup_and_discovery[sensor.house_water_level_distance-state] +# name: test_platform_setup_and_discovery[sensor.house_water_level_depth-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', - 'friendly_name': 'House Water Level Distance', + 'friendly_name': 'House Water Level Depth', 'state_class': , 'unit_of_measurement': 'm', }), 'context': , - 'entity_id': 'sensor.house_water_level_distance', + 'entity_id': 'sensor.house_water_level_depth', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5932,6 +7998,216 @@ 'state': 'upper_alarm', }) # --- +# name: test_platform_setup_and_discovery[sensor.humid_pelargonia_battery-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': , + 'entity_id': 'sensor.humid_pelargonia_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.8m3ggyvgycjwzbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humid_pelargonia_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'humid pelargonia Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humid_pelargonia_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humid_pelargonia_battery_state-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.humid_pelargonia_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.8m3ggyvgycjwzbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humid_pelargonia_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'humid pelargonia Battery state', + }), + 'context': , + 'entity_id': 'sensor.humid_pelargonia_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humid_pelargonia_humidity-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.humid_pelargonia_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.8m3ggyvgycjwzhumidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humid_pelargonia_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'humid pelargonia Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humid_pelargonia_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humid_pelargonia_temperature-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.humid_pelargonia_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.8m3ggyvgycjwztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humid_pelargonia_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'humid pelargonia Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.humid_pelargonia_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.humy_bain_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6370,6 +8646,62 @@ 'state': '6.4', }) # --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_total_energy-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.hvac_meter_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.tcdk0skzcpisexj2zcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'HVAC Meter Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.19', + }) +# --- # name: test_platform_setup_and_discovery[sensor.hvac_meter_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6701,6 +9033,57 @@ 'state': '6.1', }) # --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_total_energy-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.ineox_sp2_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.vx2owjsg86g2ys93zcadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ineox SP2 Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ineox_sp2_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.003', + }) +# --- # name: test_platform_setup_and_discovery[sensor.ineox_sp2_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6865,6 +9248,292 @@ 'state': '9.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_current-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.jie_hashuang_xiang_ji_liang_cha_zuo_current', + '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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '接HA双向计量插座 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.jie_hashuang_xiang_ji_liang_cha_zuo_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_power-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.jie_hashuang_xiang_ji_liang_cha_zuo_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '接HA双向计量插座 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.jie_hashuang_xiang_ji_liang_cha_zuo_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_total_energy-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.jie_hashuang_xiang_ji_liang_cha_zuo_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': '接HA双向计量插座 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.jie_hashuang_xiang_ji_liang_cha_zuo_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_total_production-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.jie_hashuang_xiang_ji_liang_cha_zuo_total_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total production', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_production', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzcpro_add_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_total_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': '接HA双向计量插座 Total production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.jie_hashuang_xiang_ji_liang_cha_zuo_total_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_voltage-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.jie_hashuang_xiang_ji_liang_cha_zuo_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashuang_xiang_ji_liang_cha_zuo_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '接HA双向计量插座 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.jie_hashuang_xiang_ji_liang_cha_zuo_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7063,6 +9732,118 @@ 'state': '42.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_pm2_5-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.kalado_air_purifier_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'μg/m³', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkpm25', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Kalado Air Purifier PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.kalado_air_purifier_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kattenbak_cat_weight-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.kattenbak_cat_weight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cat weight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cat_weight', + 'unique_id': 'tuya.yohkwjjdjlzludd3psmcat_weight', + 'unit_of_measurement': 'g', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kattenbak_cat_weight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'Kattenbak Cat weight', + 'state_class': , + 'unit_of_measurement': 'g', + }), + 'context': , + 'entity_id': 'sensor.kattenbak_cat_weight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.keller_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7178,6 +9959,58 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.keller_total_energy-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.keller_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.g7af6lrt4miugbstcpadd_ele', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Keller Total energy', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.keller_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- # name: test_platform_setup_and_discovery[sensor.keller_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7285,6 +10118,58 @@ 'state': 'high', }) # --- +# name: test_platform_setup_and_discovery[sensor.klarta_humea_humidity-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.klarta_humea_humidity', + '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': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.btpss2f6kwfi294rqsjhumidity_current', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.klarta_humea_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'KLARTA HUMEA Humidity', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.klarta_humea_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.lave_linge_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7400,6 +10285,62 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_total_energy-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.lave_linge_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.g0edqq0wzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lave linge Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lave_linge_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62.86', + }) +# --- # name: test_platform_setup_and_discovery[sensor.lave_linge_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7574,6 +10515,58 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_total_energy-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.licht_drucker_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzcadd_ele', + 'unit_of_measurement': '度', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Licht drucker Total energy', + 'state_class': , + 'unit_of_measurement': '度', + }), + 'context': , + 'entity_id': 'sensor.licht_drucker_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.licht_drucker_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7633,6 +10626,233 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.living_room_dehumidifier_humidity-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.living_room_dehumidifier_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.g1qorlffoy2iyo9bschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.living_room_dehumidifier_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Living room dehumidifier Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_dehumidifier_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_current-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.livr_current', + '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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.9AzrW5XtELTySJxqzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'LivR Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livr_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.081', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_power-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.livr_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.9AzrW5XtELTySJxqzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'LivR Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.livr_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '83.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_voltage-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.livr_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.9AzrW5XtELTySJxqzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.livr_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'LivR Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livr_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2352.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.lounge_dark_blind_last_operation_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7788,6 +11008,678 @@ 'state': '16.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_current-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.medidor_de_energia_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.6pd3bkidqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Medidor de Energia Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.641', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_power-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.medidor_de_energia_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.6pd3bkidqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Medidor de Energia Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.183', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_voltage-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.medidor_de_energia_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.6pd3bkidqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Medidor de Energia Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '232.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_current-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.medidor_de_energia_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.6pd3bkidqldphase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Medidor de Energia Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.007', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_power-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.medidor_de_energia_phase_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.6pd3bkidqldphase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Medidor de Energia Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.228', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_voltage-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.medidor_de_energia_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.6pd3bkidqldphase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Medidor de Energia Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_current-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.medidor_de_energia_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.6pd3bkidqldphase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Medidor de Energia Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.119', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_power-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.medidor_de_energia_phase_c_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.6pd3bkidqldphase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Medidor de Energia Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.064', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_voltage-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.medidor_de_energia_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.6pd3bkidqldphase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Medidor de Energia Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.2', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_supply_frequency-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': , + 'entity_id': 'sensor.medidor_de_energia_supply_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply frequency', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supply_frequency', + 'unique_id': 'tuya.6pd3bkidqldsupply_frequency', + 'unit_of_measurement': 'Hz', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_supply_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Medidor de Energia Supply frequency', + 'state_class': , + 'unit_of_measurement': 'Hz', + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_supply_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.02', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_total_energy-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.medidor_de_energia_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.6pd3bkidqldforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Medidor de Energia Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '135.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_total_production-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.medidor_de_energia_total_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total production', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_production', + 'unique_id': 'tuya.6pd3bkidqldreverse_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.medidor_de_energia_total_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Medidor de Energia Total production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.medidor_de_energia_total_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '125.52', + }) +# --- # name: test_platform_setup_and_discovery[sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8516,6 +12408,114 @@ 'state': '50.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_total_energy-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.metering_3pn_wifi_stable_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Metering_3PN_WiFi_stable Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24354.16', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.mirilla_puerta_battery-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': , + 'entity_id': 'sensor.mirilla_puerta_battery', + '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': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.i6xywcsymer1kmb6pswireless_electricity', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.mirilla_puerta_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mirilla puerta Battery', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.mirilla_puerta_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.motion_sensor_lidl_zigbee_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8569,6 +12569,232 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_current-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.n4_auto_current', + '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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.uc9fL2NpR79iCzGIzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'N4-Auto Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.n4_auto_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_power-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.n4_auto_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.uc9fL2NpR79iCzGIzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'N4-Auto Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.n4_auto_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_total_energy-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.n4_auto_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.uc9fL2NpR79iCzGIzcadd_ele', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'N4-Auto Total energy', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.n4_auto_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_voltage-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.n4_auto_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.uc9fL2NpR79iCzGIzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.n4_auto_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'N4-Auto Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.n4_auto_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.np_downstairs_north_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8846,6 +13072,57 @@ 'state': '38.9', }) # --- +# name: test_platform_setup_and_discovery[sensor.office_total_energy-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.office_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.2x473nefusdo7af6zcadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.office_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.013', + }) +# --- # name: test_platform_setup_and_discovery[sensor.office_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -10104,62 +14381,6 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[sensor.production_power-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.production_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'total_power', - 'unique_id': 'tuya.3phkffywh5nnlj5vbdnztotal_powerpower', - 'unit_of_measurement': 'W', - }) -# --- -# name: test_platform_setup_and_discovery[sensor.production_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Production Power', - 'state_class': , - 'unit_of_measurement': 'W', - }), - 'context': , - 'entity_id': 'sensor.production_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2314.6', - }) -# --- # name: test_platform_setup_and_discovery[sensor.production_total_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -10216,6 +14437,62 @@ 'state': '1520.21', }) # --- +# name: test_platform_setup_and_discovery[sensor.production_total_power-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.production_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnztotal_powerpower', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Production Total power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.production_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2314.6', + }) +# --- # name: test_platform_setup_and_discovery[sensor.production_total_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -10272,7 +14549,56 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_distance-entry] +# name: test_platform_setup_and_discovery[sensor.projector_screen_last_operation_duration-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.projector_screen_last_operation_duration', + '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': 'Last operation duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_operation_duration', + 'unique_id': 'tuya.aiag5pku0x39rkfllctime_total', + 'unit_of_measurement': 'ms', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.projector_screen_last_operation_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Projector Screen Last operation duration', + 'unit_of_measurement': 'ms', + }), + 'context': , + 'entity_id': 'sensor.projector_screen_last_operation_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pth_9cw_32_carbon_dioxide-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10287,7 +14613,172 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.rainwater_tank_level_distance', + 'entity_id': 'sensor.pth_9cw_32_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'ppm', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_dioxide', + 'unique_id': 'tuya.cvowstbid97lokayjb2occo2_value', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pth_9cw_32_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'PTH-9CW 32 Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.pth_9cw_32_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '450.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pth_9cw_32_humidity-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.pth_9cw_32_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.cvowstbid97lokayjb2ochumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pth_9cw_32_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'PTH-9CW 32 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pth_9cw_32_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pth_9cw_32_temperature-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.pth_9cw_32_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.cvowstbid97lokayjb2octemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pth_9cw_32_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'PTH-9CW 32 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pth_9cw_32_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_depth-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.rainwater_tank_level_depth', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10302,7 +14793,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Distance', + 'original_name': 'Depth', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, @@ -10312,16 +14803,16 @@ 'unit_of_measurement': 'm', }) # --- -# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_distance-state] +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_depth-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', - 'friendly_name': 'Rainwater Tank Level Distance', + 'friendly_name': 'Rainwater Tank Level Depth', 'state_class': , 'unit_of_measurement': 'm', }), 'context': , - 'entity_id': 'sensor.rainwater_tank_level_distance', + 'entity_id': 'sensor.rainwater_tank_level_depth', 'last_changed': , 'last_reported': , 'last_updated': , @@ -10543,6 +15034,62 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_total_energy-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.raspy4_home_assistant_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.zaszonjgzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Raspy4 - Home Assistant Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.raspy4_home_assistant_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- # name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -10746,6 +15293,59 @@ 'state': 'high', }) # --- +# name: test_platform_setup_and_discovery[sensor.salon_battery-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': , + 'entity_id': 'sensor.salon_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.gm0whbftkwbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.salon_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Salon Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.salon_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.17', + }) +# --- # name: test_platform_setup_and_discovery[sensor.sapphire_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -10920,6 +15520,59 @@ 'state': '2357.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.siren_battery-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': , + 'entity_id': 'sensor.siren_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.okwwus27jhqqe2mijbgsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.siren_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Siren Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.siren_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '77.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11179,6 +15832,62 @@ 'state': '97.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.smogo_carbon_monoxide-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.smogo_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'ppm', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': 'tuya.swhtzki3qrz5ydchjbocco_value', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smogo_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Smogo Carbon monoxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.smogo_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.smoke_alarm_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11547,6 +16256,62 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.socket3_total_energy-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.socket3_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Socket3 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket3_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.socket3_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11721,6 +16486,57 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.socket4_total_energy-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.socket4_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszcadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket4 Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.socket4_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.socket4_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11881,6 +16697,177 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.soria_power-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.soria_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.y7eeatfzbtbyllk0qbnnzpower_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.soria_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Soria Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.soria_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.06', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.soria_temperature-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.soria_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.y7eeatfzbtbyllk0qbnnztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.soria_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Soria Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.soria_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.soria_total_energy-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.soria_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.y7eeatfzbtbyllk0qbnnzreverse_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.soria_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Soria Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.soria_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '108.21', + }) +# --- # name: test_platform_setup_and_discovery[sensor.sous_vide_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -12149,6 +17136,62 @@ 'state': '1201.8', }) # --- +# name: test_platform_setup_and_discovery[sensor.spa_total_energy-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.spa_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.gi69tunb0esxcnefzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Spa Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spa_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.203', + }) +# --- # name: test_platform_setup_and_discovery[sensor.spa_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -12256,6 +17299,1262 @@ 'state': 'high', }) # --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_air_pressure-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.sws_16600_wifi_sh_air_pressure', + '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': 'Air pressure', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_pressure', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqatmospheric_pressture', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_air_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SWS 16600 WiFi SH Air pressure', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_air_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1007.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_dew_point-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.sws_16600_wifi_sh_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point_temperature', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqdew_point_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_feels_like-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.sws_16600_wifi_sh_feels_like', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feels like', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feels_like_temperature', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqfeellike_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_feels_like-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Feels like', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_feels_like', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_heat_index-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.sws_16600_wifi_sh_heat_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heat index', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heat_index_temperature', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqheat_index', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_heat_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Heat index', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_heat_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_humidity-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.sws_16600_wifi_sh_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'SWS 16600 WiFi SH Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_illuminance-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.sws_16600_wifi_sh_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'SWS 16600 WiFi SH Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7480.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_outdoor_humidity-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.sws_16600_wifi_sh_outdoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_outdoor', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqhumidity_outdoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'SWS 16600 WiFi SH Outdoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_outdoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '68.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_outdoor_humidity_channel_1-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.sws_16600_wifi_sh_outdoor_humidity_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqhumidity_outdoor_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_outdoor_humidity_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'SWS 16600 WiFi SH Outdoor humidity channel 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_outdoor_humidity_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_outdoor_humidity_channel_2-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.sws_16600_wifi_sh_outdoor_humidity_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqhumidity_outdoor_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_outdoor_humidity_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'SWS 16600 WiFi SH Outdoor humidity channel 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_outdoor_humidity_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_outdoor_humidity_channel_3-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.sws_16600_wifi_sh_outdoor_humidity_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqhumidity_outdoor_3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_outdoor_humidity_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'SWS 16600 WiFi SH Outdoor humidity channel 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_outdoor_humidity_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_precipitation_intensity-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.sws_16600_wifi_sh_precipitation_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation intensity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_intensity', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqrain_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_precipitation_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'precipitation_intensity', + 'friendly_name': 'SWS 16600 WiFi SH Precipitation intensity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_precipitation_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_probe_temperature-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.sws_16600_wifi_sh_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqtemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_probe_temperature_channel_1-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.sws_16600_wifi_sh_probe_temperature_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqtemp_current_external_1', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_probe_temperature_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Probe temperature channel 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_probe_temperature_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_probe_temperature_channel_2-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.sws_16600_wifi_sh_probe_temperature_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqtemp_current_external_2', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_probe_temperature_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Probe temperature channel 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_probe_temperature_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_probe_temperature_channel_3-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.sws_16600_wifi_sh_probe_temperature_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqtemp_current_external_3', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_probe_temperature_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Probe temperature channel 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_probe_temperature_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_temperature-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.sws_16600_wifi_sh_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_total_precipitation_today-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.sws_16600_wifi_sh_total_precipitation_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total precipitation today', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_today', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqrain_24h', + 'unit_of_measurement': 'mm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_total_precipitation_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'precipitation', + 'friendly_name': 'SWS 16600 WiFi SH Total precipitation today', + 'state_class': , + 'unit_of_measurement': 'mm', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_total_precipitation_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_uv_index-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.sws_16600_wifi_sh_uv_index', + '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': 'UV index', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxquv_index', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SWS 16600 WiFi SH UV index', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_wind_chill_index-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.sws_16600_wifi_sh_wind_chill_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind chill index', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_chill_index_temperature', + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqwindchill_index', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_wind_chill_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SWS 16600 WiFi SH Wind chill index', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_wind_chill_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_wind_speed-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.sws_16600_wifi_sh_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ai9swgb6tyinbwbxjxqwindspeed_avg', + 'unit_of_measurement': 'km/h', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sws_16600_wifi_sh_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'SWS 16600 WiFi SH Wind speed', + 'state_class': , + 'unit_of_measurement': 'km/h', + }), + 'context': , + 'entity_id': 'sensor.sws_16600_wifi_sh_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_battery-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': , + 'entity_id': 'sensor.temperature_and_humidity_sensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.ve3ctzrqgcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Temperature and humidity sensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.temperature_and_humidity_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_humidity-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.temperature_and_humidity_sensor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ve3ctzrqgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Temperature and humidity sensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.temperature_and_humidity_sensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_temperature-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.temperature_and_humidity_sensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ve3ctzrqgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.temperature_and_humidity_sensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Temperature and humidity sensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.temperature_and_humidity_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.tournesol_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -13049,6 +19348,62 @@ 'state': '1642.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_total_energy-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.varmelampa_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.sw1ejdomlmfubapizcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Värmelampa Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.varmelampa_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.082', + }) +# --- # name: test_platform_setup_and_discovery[sensor.varmelampa_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -13157,6 +19512,180 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_current-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.waschmaschine_current', + '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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.dBFBdywk9gTihUQmzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Waschmaschine Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.waschmaschine_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_power-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.waschmaschine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.dBFBdywk9gTihUQmzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Waschmaschine Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.waschmaschine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10455.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_voltage-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.waschmaschine_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.dBFBdywk9gTihUQmzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.waschmaschine_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Waschmaschine Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.waschmaschine_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2381.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -13261,6 +19790,232 @@ 'state': '7.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_current-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.weihnachten3_current', + '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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.dNBnmtjLU8eRWHf0zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Weihnachten3 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachten3_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.018', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_power-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.weihnachten3_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.dNBnmtjLU8eRWHf0zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Weihnachten3 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.weihnachten3_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_total_energy-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.weihnachten3_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.dNBnmtjLU8eRWHf0zcadd_ele', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weihnachten3 Total energy', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.weihnachten3_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_voltage-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.weihnachten3_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.dNBnmtjLU8eRWHf0zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachten3_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Weihnachten3 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachten3_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.weihnachtsmann_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -13376,6 +20131,62 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_total_energy-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.weihnachtsmann_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.rwp6kdezm97s2nktzcadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Weihnachtsmann Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachtsmann_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.weihnachtsmann_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -13922,6 +20733,59 @@ 'state': '25.1', }) # --- +# name: test_platform_setup_and_discovery[sensor.window_downstairs_battery-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': , + 'entity_id': 'sensor.window_downstairs_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.9c1vlsxoscmbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.window_downstairs_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Window downstairs Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.window_downstairs_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -14202,6 +21066,62 @@ 'state': '1.2', }) # --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_power-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.xoca_dac212xc_v2_s1_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzpower_total', + 'unit_of_measurement': 'kW', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Total power', + 'state_class': , + 'unit_of_measurement': 'kW', + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- # name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -14373,6 +21293,57 @@ 'state': '495.3', }) # --- +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_total_energy-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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_total_energy', + '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': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldadd_ele', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Total energy', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -14432,3 +21403,164 @@ 'state': '231.4', }) # --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_battery-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': , + 'entity_id': 'sensor.yinmik_water_quality_tester_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.4bxfp3kgncpcgx5uycjzsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'YINMIK Water Quality Tester Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.yinmik_water_quality_tester_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_temperature-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.yinmik_water_quality_tester_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.4bxfp3kgncpcgx5uycjzstemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'YINMIK Water Quality Tester Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yinmik_water_quality_tester_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.2', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_total_dissolved_solids-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.yinmik_water_quality_tester_total_dissolved_solids', + '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': 'Total dissolved solids', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_dissolved_solids', + 'unique_id': 'tuya.4bxfp3kgncpcgx5uycjzstds_in', + 'unit_of_measurement': 'ppt', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_total_dissolved_solids-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'YINMIK Water Quality Tester Total dissolved solids', + 'state_class': , + 'unit_of_measurement': 'ppt', + }), + 'context': , + 'entity_id': 'sensor.yinmik_water_quality_tester_total_dissolved_solids', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.476', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 97ba2e47e11..041f0eda4f5 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -486,6 +486,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.anbau_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.anbau_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.sq6fbd3pfkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.anbau_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Anbau Child lock', + }), + 'context': , + 'entity_id': 'switch.anbau_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.apollo_light_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -778,54 +826,6 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[switch.balkonbewasserung_switch-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.balkonbewasserung_switch', - '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': 'Switch', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[switch.balkonbewasserung_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'balkonbewässerung Switch', - }), - 'context': , - 'entity_id': 'switch.balkonbewasserung_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_platform_setup_and_discovery[switch.bassin_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1022,6 +1022,103 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.bathroom_radiator_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.bathroom_radiator_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.fc2ngmpckwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_radiator_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bathroom radiator Child lock', + }), + 'context': , + 'entity_id': 'switch.bathroom_radiator_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.blissradia_snooze-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.blissradia_snooze', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alarm-snooze', + 'original_name': 'Snooze', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'snooze', + 'unique_id': 'tuya.bfpewgk8r6fhmissdyzbsnooze', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.blissradia_snooze-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BlissRadia Snooze', + 'icon': 'mdi:alarm-snooze', + }), + 'context': , + 'entity_id': 'switch.blissradia_snooze', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2369,6 +2466,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.cbe_pro_2_output_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cbe_pro_2_output_power_limit', + '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': 'Output power limit', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_limit', + 'unique_id': 'tuya.gbq8kiahk57ct0bpncjynxfeedin_power_limit_enable', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cbe_pro_2_output_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CBE Pro 2 Output power limit', + }), + 'context': , + 'entity_id': 'switch.cbe_pro_2_output_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.ceiling_fan_light_v2_sound-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2710,6 +2855,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.din_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.din_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.gnZOKztbAtcBkEGPzcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.din_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Din Socket', + }), + 'context': , + 'entity_id': 'switch.din_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.droger_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2999,6 +3193,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.el_termostato_de_la_cocina_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.el_termostato_de_la_cocina_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.LmLMc0ht1KW2zYAIkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.el_termostato_de_la_cocina_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'El termostato de la cocina Child lock', + }), + 'context': , + 'entity_id': 'switch.el_termostato_de_la_cocina_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3193,6 +3435,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.empore_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.empore_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.paxijfx9fkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.empore_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Empore Child lock', + }), + 'context': , + 'entity_id': 'switch.empore_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.fakkel_veranda_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3871,54 +4161,6 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[switch.garden_valve_yard_switch-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.garden_valve_yard_switch', - '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': 'Switch', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.ggimpv4dqzkfsswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[switch.garden_valve_yard_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Garden Valve Yard Switch', - }), - 'context': , - 'entity_id': 'switch.garden_valve_yard_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_platform_setup_and_discovery[switch.hl400_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4691,6 +4933,103 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.jie_hashuang_xiang_ji_liang_cha_zuo_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.jie_hashuang_xiang_ji_liang_cha_zuo_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.jie_hashuang_xiang_ji_liang_cha_zuo_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '接HA双向计量插座 Child lock', + }), + 'context': , + 'entity_id': 'switch.jie_hashuang_xiang_ji_liang_cha_zuo_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.jie_hashuang_xiang_ji_liang_cha_zuo_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.jie_hashuang_xiang_ji_liang_cha_zuo_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.bjum5isf7h6xpbrvzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.jie_hashuang_xiang_ji_liang_cha_zuo_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '接HA双向计量插座 Socket 1', + }), + 'context': , + 'entity_id': 'switch.jie_hashuang_xiang_ji_liang_cha_zuo_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.kabinet_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5078,6 +5417,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.klarta_humea_sleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.klarta_humea_sleep', + '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': 'Sleep', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sleep', + 'unique_id': 'tuya.btpss2f6kwfi294rqsjsleep', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.klarta_humea_sleep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'KLARTA HUMEA Sleep', + }), + 'context': , + 'entity_id': 'switch.klarta_humea_sleep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.lave_linge_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5224,6 +5611,153 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.living_room_dehumidifier_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.living_room_dehumidifier_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.g1qorlffoy2iyo9bscchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.living_room_dehumidifier_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room dehumidifier Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.living_room_dehumidifier_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.living_room_dehumidifier_ionizer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.living_room_dehumidifier_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.g1qorlffoy2iyo9bscanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.living_room_dehumidifier_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room dehumidifier Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.living_room_dehumidifier_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.livr_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.livr_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.9AzrW5XtELTySJxqzcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.livr_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'LivR Socket', + }), + 'context': , + 'entity_id': 'switch.livr_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.lounge_dark_blind_reverse-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5272,6 +5806,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.mesa_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mesa_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.vpfdskpi8pr8cbtfzjschild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.mesa_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mesa Child lock', + }), + 'context': , + 'entity_id': 'switch.mesa_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.mesh_gateway_mute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5320,6 +5902,102 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.mirilla_puerta_flip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mirilla_puerta_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.i6xywcsymer1kmb6psbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.mirilla_puerta_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mirilla puerta Flip', + }), + 'context': , + 'entity_id': 'switch.mirilla_puerta_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.mirilla_puerta_time_watermark-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mirilla_puerta_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.i6xywcsymer1kmb6psbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.mirilla_puerta_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mirilla puerta Time watermark', + }), + 'context': , + 'entity_id': 'switch.mirilla_puerta_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.multifunction_alarm_arm_beep-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5416,6 +6094,55 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.n4_auto_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.n4_auto_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.uc9fL2NpR79iCzGIzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.n4_auto_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'N4-Auto Socket 1', + }), + 'context': , + 'entity_id': 'switch.n4_auto_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.office_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5948,6 +6675,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.plafond_bureau_do_not_disturb-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.plafond_bureau_do_not_disturb', + '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': 'Do not disturb', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb', + 'unique_id': 'tuya.t88qaeyydamm9xhsddxdo_not_disturb', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.plafond_bureau_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plafond bureau Do not disturb', + }), + 'context': , + 'entity_id': 'switch.plafond_bureau_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.powerplug_5_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6241,6 +7016,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.salon_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.salon_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.gm0whbftkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.salon_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Salon Child lock', + }), + 'context': , + 'entity_id': 'switch.salon_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.sapphire_socket-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6778,6 +7601,55 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.signal_repeater_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.signal_repeater_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.rvsneuipzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.signal_repeater_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Signal repeater Socket 1', + }), + 'context': , + 'entity_id': 'switch.signal_repeater_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.smart_odor_eliminator_pro_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6922,7 +7794,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[switch.smart_water_timer_switch-entry] +# name: test_platform_setup_and_discovery[switch.smart_white_noise_machine-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6935,7 +7807,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.smart_water_timer_switch', + 'entity_id': 'switch.smart_white_noise_machine', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6947,27 +7819,76 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Switch', + 'original_name': None, 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.bl5cuqxnqzkfsswitch', + 'translation_key': None, + 'unique_id': 'tuya.ri7eegdifufzdi54dyzbswitch', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[switch.smart_water_timer_switch-state] +# name: test_platform_setup_and_discovery[switch.smart_white_noise_machine-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Smart Water Timer Switch', + 'friendly_name': 'Smart White Noise Machine', }), 'context': , - 'entity_id': 'switch.smart_water_timer_switch', + 'entity_id': 'switch.smart_white_noise_machine', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_white_noise_machine_music-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.smart_white_noise_machine_music', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:music', + 'original_name': 'Music', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'music', + 'unique_id': 'tuya.ri7eegdifufzdi54dyzbswitch_music', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_white_noise_machine_music-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart White Noise Machine Music', + 'icon': 'mdi:music', + }), + 'context': , + 'entity_id': 'switch.smart_white_noise_machine_music', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', }) # --- # name: test_platform_setup_and_discovery[switch.smoke_alarm_mute-entry] @@ -7552,54 +8473,6 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[switch.sprinkler_cesare_switch-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.sprinkler_cesare_switch', - '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': 'Switch', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.tskafaotnfigad6oqzkfsswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[switch.sprinkler_cesare_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sprinkler Cesare Switch', - }), - 'context': , - 'entity_id': 'switch.sprinkler_cesare_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_platform_setup_and_discovery[switch.steckdose_2_socket-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8191,54 +9064,6 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[switch.valve_controller_2_switch-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.valve_controller_2_switch', - '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': 'Switch', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.kx8dncf1qzkfsswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[switch.valve_controller_2_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Valve Controller 2 Switch', - }), - 'context': , - 'entity_id': 'switch.valve_controller_2_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- # name: test_platform_setup_and_discovery[switch.varmelampa_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8385,6 +9210,55 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.waschmaschine_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.waschmaschine_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.dBFBdywk9gTihUQmzcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.waschmaschine_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Waschmaschine Socket', + }), + 'context': , + 'entity_id': 'switch.waschmaschine_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.water_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8529,6 +9403,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.weihnachten3_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.weihnachten3_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.dNBnmtjLU8eRWHf0zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachten3_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Weihnachten3 Socket 1', + }), + 'context': , + 'entity_id': 'switch.weihnachten3_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.weihnachtsmann_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_valve.ambr b/tests/components/tuya/snapshots/test_valve.ambr index cb5f78a5610..55d42dc56a2 100644 --- a/tests/components/tuya/snapshots/test_valve.ambr +++ b/tests/components/tuya/snapshots/test_valve.ambr @@ -1,4 +1,104 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[valve.balkonbewasserung_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.balkonbewasserung_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.balkonbewasserung_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'balkonbewässerung Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.balkonbewasserung_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.garden_valve_yard_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.garden_valve_yard_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.ggimpv4dqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.garden_valve_yard_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Garden Valve Yard Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.garden_valve_yard_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -399,3 +499,153 @@ 'state': 'closed', }) # --- +# name: test_platform_setup_and_discovery[valve.smart_water_timer_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.smart_water_timer_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.bl5cuqxnqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.smart_water_timer_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Smart Water Timer Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.smart_water_timer_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[valve.sprinkler_cesare_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.sprinkler_cesare_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.tskafaotnfigad6oqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.sprinkler_cesare_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Sprinkler Cesare Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.sprinkler_cesare_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.valve_controller_2_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.valve_controller_2_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.kx8dncf1qzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.valve_controller_2_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Valve Controller 2 Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve_controller_2_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/test_alarm_control_panel.py b/tests/components/tuya/test_alarm_control_panel.py index 53721b1add0..20e189acd23 100644 --- a/tests/components/tuya/test_alarm_control_panel.py +++ b/tests/components/tuya/test_alarm_control_panel.py @@ -5,9 +5,8 @@ from __future__ import annotations from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,7 +19,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index 4da79effde7..a06b585c8a2 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -6,9 +6,8 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,7 +21,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -51,7 +50,7 @@ async def test_platform_setup_and_discovery( @patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_bitmap( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, mock_listener: MockDeviceListener, diff --git a/tests/components/tuya/test_button.py b/tests/components/tuya/test_button.py index e9a7b43e103..971aa877e3f 100644 --- a/tests/components/tuya/test_button.py +++ b/tests/components/tuya/test_button.py @@ -5,9 +5,8 @@ from __future__ import annotations from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,7 +19,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py index 94295fe1191..4c2dc5e35ca 100644 --- a/tests/components/tuya/test_camera.py +++ b/tests/components/tuya/test_camera.py @@ -6,9 +6,8 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -31,7 +30,7 @@ def mock_getrandbits(): @patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index a0da9359ea3..769078361f8 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.climate import ( ATTR_FAN_MODE, @@ -17,7 +17,6 @@ from homeassistant.components.climate import ( SERVICE_SET_HUMIDITY, SERVICE_SET_TEMPERATURE, ) -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported @@ -31,7 +30,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -49,7 +48,7 @@ async def test_platform_setup_and_discovery( ) async def test_set_temperature( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -79,7 +78,7 @@ async def test_set_temperature( ) async def test_fan_mode_windspeed( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -110,7 +109,7 @@ async def test_fan_mode_windspeed( ) async def test_fan_mode_no_valid_code( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -144,7 +143,7 @@ async def test_fan_mode_no_valid_code( ) async def test_set_humidity_not_supported( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 7206aaf1cff..e4d6d98250a 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -18,7 +18,6 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, ) -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported @@ -32,7 +31,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -51,7 +50,7 @@ async def test_platform_setup_and_discovery( @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_open_service( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -85,7 +84,7 @@ async def test_open_service( @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_close_service( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -118,7 +117,7 @@ async def test_close_service( ) async def test_set_position( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -160,7 +159,7 @@ async def test_set_position( @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_percent_state_on_cover( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, percent_control: int, @@ -185,7 +184,7 @@ async def test_percent_state_on_cover( ) async def test_set_tilt_position_not_supported( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -205,3 +204,110 @@ async def test_set_tilt_position_not_supported( }, blocking=True, ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["clkg_wltqkykhni0papzj"], +) +@pytest.mark.parametrize( + ("initial_percent_control", "expected_state", "expected_position"), + [ + (0, "closed", 0), + (25, "open", 25), + (50, "open", 50), + (75, "open", 75), + (100, "open", 100), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_clkg_wltqkykhni0papzj_state( + hass: HomeAssistant, + mock_manager: Manager, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + initial_percent_control: int, + expected_state: str, + expected_position: int, +) -> None: + """Test cover position for wltqkykhni0papzj device. + + See https://github.com/home-assistant/core/issues/151635 + percent_control == 0 is when my roller shutter is completely open (meaning up) + percent_control == 100 is when my roller shutter is completely closed (meaning down) + """ + entity_id = "cover.roller_shutter_living_room_curtain" + mock_device.status["percent_control"] = initial_percent_control + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.state == expected_state + assert state.attributes[ATTR_CURRENT_POSITION] == expected_position + + +@pytest.mark.parametrize( + "mock_device_code", + ["clkg_wltqkykhni0papzj"], +) +@pytest.mark.parametrize( + ("service_name", "service_kwargs", "expected_commands"), + [ + ( + SERVICE_OPEN_COVER, + {}, + [ + {"code": "control", "value": "open"}, + {"code": "percent_control", "value": 100}, + ], + ), + ( + SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 25}, + [ + {"code": "percent_control", "value": 25}, + ], + ), + ( + SERVICE_CLOSE_COVER, + {}, + [ + {"code": "control", "value": "close"}, + {"code": "percent_control", "value": 0}, + ], + ), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_clkg_wltqkykhni0papzj_action( + hass: HomeAssistant, + mock_manager: Manager, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + service_name: str, + service_kwargs: dict, + expected_commands: list[dict], +) -> None: + """Test cover position for wltqkykhni0papzj device. + + See https://github.com/home-assistant/core/issues/151635 + percent_control == 0 is when my roller shutter is completely open (meaning up) + percent_control == 100 is when my roller shutter is completely closed (meaning down) + """ + entity_id = "cover.roller_shutter_living_room_curtain" + mock_device.status["percent_control"] = 50 + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await hass.services.async_call( + COVER_DOMAIN, + service_name, + {ATTR_ENTITY_ID: entity_id, **service_kwargs}, + blocking=True, + ) + + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + expected_commands, + ) diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py index f07c2faa229..aff84edf231 100644 --- a/tests/components/tuya/test_diagnostics.py +++ b/tests/components/tuya/test_diagnostics.py @@ -5,9 +5,8 @@ from __future__ import annotations import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.components.tuya.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -25,7 +24,7 @@ from tests.typing import ClientSessionGenerator @pytest.mark.parametrize("mock_device_code", ["rqbj_4iqe2hsfyd86kwwc"]) async def test_entry_diagnostics( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, hass_client: ClientSessionGenerator, @@ -46,7 +45,7 @@ async def test_entry_diagnostics( @pytest.mark.parametrize("mock_device_code", ["rqbj_4iqe2hsfyd86kwwc"]) async def test_device_diagnostics( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, hass_client: ClientSessionGenerator, diff --git a/tests/components/tuya/test_event.py b/tests/components/tuya/test_event.py index 6e493ae41c0..ec69b58781e 100644 --- a/tests/components/tuya/test_event.py +++ b/tests/components/tuya/test_event.py @@ -5,9 +5,8 @@ from __future__ import annotations from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,7 +19,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py index 992c989e352..d45103ddd05 100644 --- a/tests/components/tuya/test_fan.py +++ b/tests/components/tuya/test_fan.py @@ -5,9 +5,8 @@ from __future__ import annotations from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,7 +19,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index c38e5521990..2cdf5534b08 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.humidifier import ( ATTR_HUMIDITY, @@ -15,7 +15,6 @@ from homeassistant.components.humidifier import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -29,7 +28,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -47,7 +46,7 @@ async def test_platform_setup_and_discovery( ) async def test_turn_on( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -74,7 +73,7 @@ async def test_turn_on( ) async def test_turn_off( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -101,7 +100,7 @@ async def test_turn_off( ) async def test_set_humidity( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -131,7 +130,7 @@ async def test_set_humidity( ) async def test_turn_on_unsupported( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -166,7 +165,7 @@ async def test_turn_on_unsupported( ) async def test_turn_off_unsupported( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -201,7 +200,7 @@ async def test_turn_off_unsupported( ) async def test_set_humidity_unsupported( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index 545a5a7f07c..a3ac054902f 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -3,9 +3,8 @@ from __future__ import annotations from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.components.tuya.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -17,7 +16,7 @@ from tests.common import MockConfigEntry, async_load_json_object_fixture async def test_device_registry( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: CustomerDevice, device_registry: dr.DeviceRegistry, diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index e87eb139385..45067f779b7 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -16,7 +16,6 @@ from homeassistant.components.light import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -29,7 +28,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -92,7 +91,7 @@ async def test_platform_setup_and_discovery( ) async def test_turn_on_white( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, turn_on_input: dict[str, Any], @@ -125,7 +124,7 @@ async def test_turn_on_white( ) async def test_turn_off( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index 89124fdf65f..e5587ade008 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -6,14 +6,13 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -27,7 +26,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -45,7 +44,7 @@ async def test_platform_setup_and_discovery( ) async def test_set_value( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -75,7 +74,7 @@ async def test_set_value( ) async def test_set_value_no_function( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index c35963528d4..ecc9570584d 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -6,14 +6,13 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -27,7 +26,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -45,7 +44,7 @@ async def test_platform_setup_and_discovery( ) async def test_select_option( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -75,7 +74,7 @@ async def test_select_option( ) async def test_select_invalid_option( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index a5d61ea47a6..034f19ea7ae 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -6,9 +6,8 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,7 +21,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py index 1043c0a3a0f..465d5eab631 100644 --- a/tests/components/tuya/test_siren.py +++ b/tests/components/tuya/test_siren.py @@ -5,9 +5,8 @@ from __future__ import annotations from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,7 +19,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index e763fe3bd91..6124c54b5a9 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -4,13 +4,15 @@ from __future__ import annotations from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.tuya import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import initialize_entry @@ -20,7 +22,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -30,3 +32,54 @@ async def test_platform_setup_and_discovery( await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("preexisting_entity", "disabled_by", "expected_entity", "expected_issue"), + [ + (True, None, True, True), + (True, er.RegistryEntryDisabler.USER, False, False), + (False, None, False, False), + ], +) +@pytest.mark.parametrize( + "mock_device_code", + ["sfkzq_rzklytdei8i8vo37"], +) +async def test_sfkzq_deprecated_switch( + hass: HomeAssistant, + mock_manager: Manager, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + preexisting_entity: bool, + disabled_by: er.RegistryEntryDisabler, + expected_entity: bool, + expected_issue: bool, +) -> None: + """Test switch deprecation issue.""" + original_entity_id = "switch.balkonbewasserung_switch" + entity_unique_id = "tuya.73ov8i8iedtylkzrqzkfsswitch" + if preexisting_entity: + suggested_id = original_entity_id.replace(f"{SWITCH_DOMAIN}.", "") + entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + entity_unique_id, + suggested_object_id=suggested_id, + disabled_by=disabled_by, + ) + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert ( + entity_registry.async_get(original_entity_id) is not None + ) is expected_entity + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{entity_unique_id}", + ) + is not None + ) is expected_issue diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py index 5ee5b965137..545a9b2bc8b 100644 --- a/tests/components/tuya/test_vacuum.py +++ b/tests/components/tuya/test_vacuum.py @@ -6,9 +6,8 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, @@ -25,7 +24,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -43,7 +42,7 @@ async def test_platform_setup_and_discovery( ) async def test_return_home( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py index 73ccfba7fc4..9f2c402500d 100644 --- a/tests/components/tuya/test_valve.py +++ b/tests/components/tuya/test_valve.py @@ -6,9 +6,8 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.tuya import ManagerCompat from homeassistant.components.valve import ( DOMAIN as VALVE_DOMAIN, SERVICE_CLOSE_VALVE, @@ -26,7 +25,7 @@ from tests.common import MockConfigEntry, snapshot_platform @patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) async def test_platform_setup_and_discovery( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, @@ -38,13 +37,14 @@ async def test_platform_setup_and_discovery( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) @pytest.mark.parametrize( "mock_device_code", ["sfkzq_ed7frwissyqrejic"], ) async def test_open_valve( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: @@ -67,13 +67,14 @@ async def test_open_valve( ) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) @pytest.mark.parametrize( "mock_device_code", ["sfkzq_ed7frwissyqrejic"], ) async def test_close_valve( hass: HomeAssistant, - mock_manager: ManagerCompat, + mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index d961e1ed4f0..7a34884d160 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -1,7 +1,7 @@ """Tests for the Twitch component.""" from collections.abc import AsyncGenerator, AsyncIterator -from typing import Any, Generic, TypeVar +from typing import Any from twitchAPI.object.base import TwitchObject @@ -20,10 +20,7 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.async_block_till_done() -TwitchType = TypeVar("TwitchType", bound=TwitchObject) - - -class TwitchIterObject(Generic[TwitchType]): +class TwitchIterObject[TwitchT: TwitchObject]: """Twitch object iterator.""" raw_data: JsonArrayType @@ -31,14 +28,14 @@ class TwitchIterObject(Generic[TwitchType]): total: int def __init__( - self, hass: HomeAssistant, fixture: str, target_type: type[TwitchType] + self, hass: HomeAssistant, fixture: str, target_type: type[TwitchT] ) -> None: """Initialize object.""" self.hass = hass self.fixture = fixture self.target_type = target_type - async def __aiter__(self) -> AsyncIterator[TwitchType]: + async def __aiter__(self) -> AsyncIterator[TwitchT]: """Return async iterator.""" if not hasattr(self, "raw_data"): self.raw_data = await async_load_json_array_fixture( @@ -50,18 +47,18 @@ class TwitchIterObject(Generic[TwitchType]): yield item -async def get_generator( - hass: HomeAssistant, fixture: str, target_type: type[TwitchType] -) -> AsyncGenerator[TwitchType]: +async def get_generator[TwitchT: TwitchObject]( + hass: HomeAssistant, fixture: str, target_type: type[TwitchT] +) -> AsyncGenerator[TwitchT]: """Return async generator.""" data = await async_load_json_array_fixture(hass, fixture, DOMAIN) async for item in get_generator_from_data(data, target_type): yield item -async def get_generator_from_data( - items: list[dict[str, Any]], target_type: type[TwitchType] -) -> AsyncGenerator[TwitchType]: +async def get_generator_from_data[TwitchT: TwitchObject]( + items: list[dict[str, Any]], target_type: type[TwitchT] +) -> AsyncGenerator[TwitchT]: """Return async generator.""" for item in items: yield target_type(**item) diff --git a/tests/components/unifi/snapshots/test_switch.ambr b/tests/components/unifi/snapshots/test_switch.ambr index 017fe237025..4fabff5d278 100644 --- a/tests/components/unifi/snapshots/test_switch.ambr +++ b/tests/components/unifi/snapshots/test_switch.ambr @@ -194,6 +194,55 @@ 'state': 'on', }) # --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_name_port_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Port 1', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_control', + 'unique_id': 'port-10:00:00:00:01:01_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'mock-name Port 1', + }), + 'context': , + 'entity_id': 'switch.mock_name_port_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_1_poe-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -243,6 +292,55 @@ 'state': 'on', }) # --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_name_port_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Port 2', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_control', + 'unique_id': 'port-10:00:00:00:01:01_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'mock-name Port 2', + }), + 'context': , + 'entity_id': 'switch.mock_name_port_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_2_poe-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -292,6 +390,104 @@ 'state': 'on', }) # --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_name_port_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Port 3', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_control', + 'unique_id': 'port-10:00:00:00:01:01_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'mock-name Port 3', + }), + 'context': , + 'entity_id': 'switch.mock_name_port_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_name_port_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Port 4', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_control', + 'unique_id': 'port-10:00:00:00:01:01_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'mock-name Port 4', + }), + 'context': , + 'entity_id': 'switch.mock_name_port_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_4_poe-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/unifi/test_services.py b/tests/components/unifi/test_services.py index 8f06359fb6b..95a0fce6c59 100644 --- a/tests/components/unifi/test_services.py +++ b/tests/components/unifi/test_services.py @@ -1,4 +1,4 @@ -"""deCONZ service tests.""" +"""UniFi service tests.""" from typing import Any from unittest.mock import PropertyMock, patch diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index c14ecbc0b06..707f52ce1bc 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -150,6 +150,7 @@ DEVICE_1 = { "portconf_id": "1a1", "port_poe": True, "up": True, + "enable": True, }, { "media": "GE", @@ -164,6 +165,7 @@ DEVICE_1 = { "portconf_id": "1a2", "port_poe": True, "up": True, + "enable": True, }, { "media": "GE", @@ -178,6 +180,7 @@ DEVICE_1 = { "portconf_id": "1a3", "port_poe": False, "up": True, + "enable": True, }, { "media": "GE", @@ -192,6 +195,7 @@ DEVICE_1 = { "portconf_id": "1a4", "port_poe": True, "up": True, + "enable": True, }, ], "state": 1, @@ -1727,6 +1731,7 @@ async def test_port_forwarding_switches( "portconf_id": "1a1", "port_poe": True, "up": True, + "enable": True, }, ], }, @@ -1783,6 +1788,7 @@ async def test_hub_state_change( entity_ids = ( "switch.block_client_2", "switch.mock_name_port_1_poe", + "switch.mock_name_port_1", "switch.plug_outlet_1", "switch.block_media_streaming", "switch.unifi_network_plex", @@ -1802,3 +1808,106 @@ async def test_hub_state_change( await mock_websocket_state.reconnect() for entity_id in entity_ids: assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) +async def test_port_control_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + mock_websocket_message: WebsocketMessageMock, + device_payload: list[dict[str, Any]], +) -> None: + """Test port control entities work.""" + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + ent_reg_entry = entity_registry.async_get("switch.mock_name_port_1") + assert ( + ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + ) # ✅ Disabled by default + + # Enable entity + entity_registry.async_update_entity( + entity_id="switch.mock_name_port_1", disabled_by=None + ) + entity_registry.async_update_entity( + entity_id="switch.mock_name_port_2", disabled_by=None + ) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Validate state object + assert hass.states.get("switch.mock_name_port_1").state == STATE_ON + + # Update state object - disable port via port_overrides + device_1 = deepcopy(device_payload[0]) + device_1["port_table"][0]["enable"] = False + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1").state == STATE_OFF + + # Turn off port + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.mock_name_port_1"}, + blocking=True, + ) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "port_overrides": [ + {"port_idx": 1, "port_security_enabled": True, "portconf_id": "1a1"} + ] + } + + # Turn on port + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.mock_name_port_1"}, + blocking=True, + ) + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.mock_name_port_2"}, + blocking=True, + ) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert aioclient_mock.call_count == 3 + assert aioclient_mock.mock_calls[1][2] == { + "port_overrides": [ + {"port_idx": 1, "port_security_enabled": False, "portconf_id": "1a1"}, + ] + } + assert aioclient_mock.mock_calls[2][2] == { + "port_overrides": [ + {"port_idx": 2, "port_security_enabled": True, "portconf_id": "1a2"}, + ] + } + # Device gets disabled + device_1["disabled"] = True + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1").state == STATE_UNAVAILABLE + + # Device gets re-enabled + device_1["disabled"] = False + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1").state == STATE_OFF diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index 2d08630e520..6bd42b5b39a 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -5,7 +5,7 @@ from __future__ import annotations from copy import deepcopy from unittest.mock import AsyncMock -from uiprotect.data import Camera, CloudAccount, ModelType, Version +from uiprotect.data import Camera, CloudAccount, Version from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH @@ -77,6 +77,7 @@ async def test_rtsp_read_only_ignore( user.all_permissions = [] ufp.api.get_camera = AsyncMock(return_value=doorbell) + ufp.api.create_camera_rtsps_streams = AsyncMock(return_value=None) await init_entry(hass, ufp, [doorbell]) await async_process_repairs_platforms(hass) @@ -133,6 +134,7 @@ async def test_rtsp_read_only_fix( new_doorbell = deepcopy(doorbell) new_doorbell.channels[1].is_rtsp_enabled = True ufp.api.get_camera = AsyncMock(return_value=new_doorbell) + ufp.api.create_camera_rtsps_streams = AsyncMock(return_value=None) issue_id = f"rtsp_disabled_{doorbell.id}" await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) @@ -175,8 +177,9 @@ async def test_rtsp_writable_fix( new_doorbell = deepcopy(doorbell) new_doorbell.channels[0].is_rtsp_enabled = True + ufp.api.get_camera = AsyncMock(side_effect=[doorbell, new_doorbell]) - ufp.api.update_device = AsyncMock() + ufp.api.create_camera_rtsps_streams = AsyncMock(return_value=None) issue_id = f"rtsp_disabled_{doorbell.id}" await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) @@ -199,11 +202,7 @@ async def test_rtsp_writable_fix( assert data["type"] == "create_entry" - channels = doorbell.unifi_dict()["channels"] - channels[0]["isRtspEnabled"] = True - ufp.api.update_device.assert_called_with( - ModelType.CAMERA, doorbell.id, {"channels": channels} - ) + ufp.api.create_camera_rtsps_streams.assert_called_with(doorbell.id, "high") async def test_rtsp_writable_fix_when_not_setup( @@ -225,8 +224,9 @@ async def test_rtsp_writable_fix_when_not_setup( new_doorbell = deepcopy(doorbell) new_doorbell.channels[0].is_rtsp_enabled = True + ufp.api.get_camera = AsyncMock(side_effect=[doorbell, new_doorbell]) - ufp.api.update_device = AsyncMock() + ufp.api.create_camera_rtsps_streams = AsyncMock(return_value=None) issue_id = f"rtsp_disabled_{doorbell.id}" await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) @@ -254,11 +254,7 @@ async def test_rtsp_writable_fix_when_not_setup( assert data["type"] == "create_entry" - channels = doorbell.unifi_dict()["channels"] - channels[0]["isRtspEnabled"] = True - ufp.api.update_device.assert_called_with( - ModelType.CAMERA, doorbell.id, {"channels": channels} - ) + ufp.api.create_camera_rtsps_streams.assert_called_with(doorbell.id, "high") async def test_rtsp_no_fix_if_third_party( diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index 99f16fcbb75..bf9f0502e35 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -74,7 +74,7 @@ async def test_text_camera_set( assert_entity_counts(hass, Platform.TEXT, 1, 1) description = CAMERA[0] - unique_id, entity_id = await ids_from_device_description( + _unique_id, entity_id = await ids_from_device_description( hass, Platform.TEXT, doorbell, description ) diff --git a/tests/components/universal/fixtures/configuration.yaml b/tests/components/universal/fixtures/configuration.yaml index c3e445615f1..2614c9b27fd 100644 --- a/tests/components/universal/fixtures/configuration.yaml +++ b/tests/components/universal/fixtures/configuration.yaml @@ -7,3 +7,4 @@ media_player: state: remote.alexander_master_bedroom source_list: remote.alexander_master_bedroom|activity_list source: remote.alexander_master_bedroom|current_activity + entity_picture: remote.alexander_master_bedroom|entity_picture diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 1418a5b7dac..c04145ad25f 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -266,6 +266,8 @@ async def mock_states(hass: HomeAssistant) -> Mock: result.mock_repeat_switch_id = switch.ENTITY_ID_FORMAT.format("repeat") hass.states.async_set(result.mock_repeat_switch_id, STATE_OFF) + result.mock_media_image_url_id = f"{input_select.DOMAIN}.entity_picture" + hass.states.async_set(result.mock_media_image_url_id, "/local/picture.png") return result @@ -289,6 +291,7 @@ def config_children_and_attr(mock_states): "repeat": mock_states.mock_repeat_switch_id, "sound_mode_list": mock_states.mock_sound_mode_list_id, "sound_mode": mock_states.mock_sound_mode_id, + "entity_picture": mock_states.mock_media_image_url_id, }, } @@ -598,6 +601,22 @@ async def test_sound_mode_list_children_and_attr( assert ump.sound_mode_list == "['music', 'movie', 'game']" +async def test_entity_picture_children_and_attr( + hass: HomeAssistant, config_children_and_attr, mock_states +) -> None: + """Test entity picture property w/ children and attrs.""" + config = validate_config(config_children_and_attr) + + ump = universal.UniversalMediaPlayer(hass, config) + + assert ump.entity_picture == "/local/picture.png" + + hass.states.async_set( + mock_states.mock_sound_mode_list_id, "/local/other_picture.png" + ) + assert ump.sound_mode_list == "/local/other_picture.png" + + async def test_source_list_children_and_attr( hass: HomeAssistant, config_children_and_attr, mock_states ) -> None: @@ -774,6 +793,7 @@ async def test_overrides(hass: HomeAssistant, config_children_and_attr) -> None: "clear_playlist": excmd, "play_media": excmd, "toggle": excmd, + "entity_picture": excmd, } await async_setup_component(hass, "media_player", {"media_player": config}) await hass.async_block_till_done() @@ -1364,7 +1384,11 @@ async def test_reload(hass: HomeAssistant) -> None: hass.states.async_set( "remote.alexander_master_bedroom", STATE_ON, - {"activity_list": ["act1", "act2"], "current_activity": "act2"}, + { + "activity_list": ["act1", "act2"], + "current_activity": "act2", + "entity_picture": "/local/picture_remote.png", + }, ) yaml_path = get_fixture_path("configuration.yaml", "universal") @@ -1382,6 +1406,10 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("media_player.tv") is None assert hass.states.get("media_player.master_bed_tv").state == "on" assert hass.states.get("media_player.master_bed_tv").attributes["source"] == "act2" + assert ( + hass.states.get("media_player.master_bed_tv").attributes["entity_picture"] + == "/local/picture_remote.png" + ) assert ( "device_class" not in hass.states.get("media_player.master_bed_tv").attributes ) diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py index 01f003327c1..7a404e3d877 100644 --- a/tests/components/uptimerobot/common.py +++ b/tests/components/uptimerobot/common.py @@ -80,7 +80,7 @@ class MockApiResponseKey(str, Enum): def mock_uptimerobot_api_response( - data: dict[str, Any] + data: list[dict[str, Any]] | list[UptimeRobotMonitor] | UptimeRobotAccount | UptimeRobotApiError @@ -115,8 +115,10 @@ async def setup_uptimerobot_integration(hass: HomeAssistant) -> MockConfigEntry: assert await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON - assert hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY).state == STATE_UP + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UP assert mock_entry.state is ConfigEntryState.LOADED return mock_entry diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 3de9b9ec399..13e4a556d18 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -16,6 +16,7 @@ from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_MONITOR, UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY, + mock_uptimerobot_api_response, setup_uptimerobot_integration, ) @@ -26,8 +27,7 @@ async def test_presentation(hass: HomeAssistant) -> None: """Test the presenstation of UptimeRobot binary_sensors.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) - + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert entity.state == STATE_ON assert entity.attributes["device_class"] == BinarySensorDeviceClass.CONNECTIVITY assert entity.attributes["attribution"] == ATTRIBUTION @@ -38,7 +38,7 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: """Test entity unavailable on update failure.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert entity.state == STATE_ON with patch( @@ -48,5 +48,45 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() - entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert entity.state == STATE_UNAVAILABLE + + +async def test_binary_sensor_dynamic(hass: HomeAssistant) -> None: + """Test binary_sensor dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + + entity_id_2 = "binary_sensor.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_ON diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index c7ae6a5d772..ce6ec7cfcf7 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -3,7 +3,11 @@ from unittest.mock import patch import pytest -from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException +from pyuptimerobot import ( + UptimeRobotApiResponse, + UptimeRobotAuthenticationException, + UptimeRobotException, +) from homeassistant import config_entries from homeassistant.components.uptimerobot.const import DOMAIN @@ -35,7 +39,7 @@ async def test_user(hass: HomeAssistant) -> None: with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( @@ -66,7 +70,7 @@ async def test_user_key_read_only(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ): result2 = await hass.config_entries.flow.async_configure( @@ -76,6 +80,7 @@ async def test_user_key_read_only(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == "not_main_key" @@ -94,7 +99,7 @@ async def test_exception_thrown(hass: HomeAssistant, exception, error_key) -> No ) with patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", side_effect=exception, ): result2 = await hass.config_entries.flow.async_configure( @@ -103,6 +108,7 @@ async def test_exception_thrown(hass: HomeAssistant, exception, error_key) -> No ) assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == error_key @@ -113,7 +119,7 @@ async def test_api_error(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) ) with patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ): result2 = await hass.config_entries.flow.async_configure( @@ -121,6 +127,7 @@ async def test_api_error(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) + assert result2["errors"] assert result2["errors"]["base"] == "unknown" assert "test error from API." in caplog.text @@ -140,7 +147,7 @@ async def test_user_unique_id_already_exists( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( @@ -174,7 +181,7 @@ async def test_reauthentication( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( @@ -207,7 +214,7 @@ async def test_reauthentication_failure( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ), patch( @@ -223,6 +230,7 @@ async def test_reauthentication_failure( assert result2["step_id"] == "reauth_confirm" assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == "unknown" @@ -243,7 +251,7 @@ async def test_reauthentication_failure_no_existing_entry( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( @@ -276,7 +284,7 @@ async def test_reauthentication_failure_account_not_matching( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response( key=MockApiResponseKey.ACCOUNT, data={**MOCK_UPTIMEROBOT_ACCOUNT, "user_id": 1234567891}, @@ -295,4 +303,182 @@ async def test_reauthentication_failure_account_not_matching( assert result2["step_id"] == "reauth_confirm" assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == "reauth_failed_matching_account" + + +async def test_reconfigure_successful( + hass: HomeAssistant, +) -> None: + """Test that the entry can be reconfigured.""" + config_entry = MockConfigEntry( + **{**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, "unique_id": None} + ) + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "reconfigure" + + new_key = "u0242ac120003-new" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: new_key}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # changed entry + assert config_entry.data[CONF_API_KEY] == new_key + + +async def test_reconfigure_failed( + hass: HomeAssistant, +) -> None: + """Test that the entry reconfigure fails with a wrong key.""" + config_entry = MockConfigEntry( + **{**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, "unique_id": None} + ) + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "reconfigure" + + wrong_key = "u0242ac120003-wrong" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + side_effect=UptimeRobotAuthenticationException, + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: wrong_key}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] + assert result2["errors"]["base"] == "invalid_api_key" + + new_key = "u0242ac120003-new" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_API_KEY: new_key}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + # changed entry + assert config_entry.data[CONF_API_KEY] == new_key + + +async def test_reconfigure_with_key_present( + hass: HomeAssistant, +) -> None: + """Test that the entry reconfigure fails with a key from another entry.""" + config_entry = MockConfigEntry( + **{**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, "unique_id": None} + ) + config_entry.add_to_hass(hass) + + api_key_2 = "u0242ac120003-2" + email_2 = "test2@test.test" + user_id_2 = "abcdefghil" + data2 = { + "domain": DOMAIN, + "title": email_2, + "data": {"platform": DOMAIN, "api_key": api_key_2}, + "unique_id": user_id_2, + "source": config_entries.SOURCE_USER, + } + config_entry_2 = MockConfigEntry(**{**data2, "unique_id": None}) + config_entry_2.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "reconfigure" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "email": email_2, + "user_id": user_id_2, + "up_monitors": 1, + } + ), + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: api_key_2}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {} + assert result2["step_id"] == "reconfigure" + + new_key = "u0242ac120003-new" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_API_KEY: new_key}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + # changed entry + assert config_entry.data[CONF_API_KEY] == new_key diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 435b0737c6d..d252501aa28 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -102,7 +102,7 @@ async def test_reauthentication_trigger_after_setup( """Test reauthentication trigger.""" mock_config_entry = await setup_uptimerobot_integration(hass) - binary_sensor = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) + assert (binary_sensor := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert mock_config_entry.state is ConfigEntryState.LOADED assert binary_sensor.state == STATE_ON @@ -115,10 +115,8 @@ async def test_reauthentication_trigger_after_setup( await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() - assert ( - hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state - == STATE_UNAVAILABLE - ) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UNAVAILABLE assert "Authentication failed while fetching uptimerobot data" in caplog.text @@ -146,9 +144,10 @@ async def test_integration_reload( async_fire_time_changed(hass) await hass.async_block_till_done() - entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert (entry := hass.config_entries.async_get_entry(mock_entry.entry_id)) assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON async def test_update_errors( @@ -166,10 +165,8 @@ async def test_update_errors( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert ( - hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state - == STATE_UNAVAILABLE - ) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UNAVAILABLE with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -178,7 +175,8 @@ async def test_update_errors( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -187,10 +185,8 @@ async def test_update_errors( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert ( - hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state - == STATE_UNAVAILABLE - ) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UNAVAILABLE assert "Error fetching uptimerobot data: test error from API" in caplog.text @@ -209,7 +205,8 @@ async def test_device_management( assert devices[0].identifiers == {(DOMAIN, "1234")} assert devices[0].name == "Test monitor" - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON assert hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2") is None with patch( @@ -227,10 +224,10 @@ async def test_device_management( assert devices[0].identifiers == {(DOMAIN, "1234")} assert devices[1].identifiers == {(DOMAIN, "12345")} - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON - assert ( - hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2").state == STATE_ON - ) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + assert (entity2 := hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2")) + assert entity2.state == STATE_ON with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -244,5 +241,6 @@ async def test_device_management( assert len(devices) == 1 assert devices[0].identifiers == {(DOMAIN, "1234")} - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON assert hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2") is None diff --git a/tests/components/uptimerobot/test_sensor.py b/tests/components/uptimerobot/test_sensor.py index 8cee33c1052..26f7432f99c 100644 --- a/tests/components/uptimerobot/test_sensor.py +++ b/tests/components/uptimerobot/test_sensor.py @@ -14,6 +14,7 @@ from .common import ( MOCK_UPTIMEROBOT_MONITOR, STATE_UP, UPTIMEROBOT_SENSOR_TEST_ENTITY, + mock_uptimerobot_api_response, setup_uptimerobot_integration, ) @@ -24,8 +25,7 @@ async def test_presentation(hass: HomeAssistant) -> None: """Test the presentation of UptimeRobot sensors.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) - + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) is not None assert entity.state == STATE_UP assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] assert entity.attributes["device_class"] == SensorDeviceClass.ENUM @@ -42,7 +42,7 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: """Test entity unavailable on update failure.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) is not None assert entity.state == STATE_UP with patch( @@ -52,5 +52,45 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() - entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) is not None assert entity.state == STATE_UNAVAILABLE + + +async def test_sensor_dynamic(hass: HomeAssistant) -> None: + """Test sensor dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UP + + entity_id_2 = "sensor.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UP + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_UP diff --git a/tests/components/uptimerobot/test_switch.py b/tests/components/uptimerobot/test_switch.py index 48e9da05720..e42b46db861 100644 --- a/tests/components/uptimerobot/test_switch.py +++ b/tests/components/uptimerobot/test_switch.py @@ -6,6 +6,7 @@ import pytest from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.uptimerobot.const import COORDINATOR_UPDATE_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -15,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, @@ -26,15 +28,14 @@ from .common import ( setup_uptimerobot_integration, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_presentation(hass: HomeAssistant) -> None: """Test the presentation of UptimeRobot switches.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) - + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] @@ -67,12 +68,12 @@ async def test_switch_off(hass: HomeAssistant) -> None: blocking=True, ) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_OFF async def test_switch_on(hass: HomeAssistant) -> None: - """Test entity unaviable on update failure.""" + """Test entity unavailable on update failure.""" mock_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) mock_entry.add_to_hass(hass) @@ -97,7 +98,7 @@ async def test_switch_on(hass: HomeAssistant) -> None: blocking=True, ) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON @@ -107,7 +108,7 @@ async def test_authentication_error( """Test authentication error turning switch on/off.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON with ( @@ -133,7 +134,7 @@ async def test_action_execution_failure(hass: HomeAssistant) -> None: """Test turning switch on/off failure.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON with ( @@ -161,7 +162,7 @@ async def test_switch_api_failure(hass: HomeAssistant) -> None: """Test general exception turning switch on/off.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON with patch( @@ -181,3 +182,43 @@ async def test_switch_api_failure(hass: HomeAssistant) -> None: assert exc_info.value.translation_placeholders == { "error": "test error from API." } + + +async def test_switch_dynamic(hass: HomeAssistant) -> None: + """Test switch dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) + assert entity.state == STATE_ON + + entity_id_2 = "switch.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) + assert entity.state == STATE_ON + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_ON diff --git a/tests/components/usage_prediction/__init__.py b/tests/components/usage_prediction/__init__.py new file mode 100644 index 00000000000..124766b0c39 --- /dev/null +++ b/tests/components/usage_prediction/__init__.py @@ -0,0 +1 @@ +"""Tests for the usage_prediction integration.""" diff --git a/tests/components/usage_prediction/test_common_control.py b/tests/components/usage_prediction/test_common_control.py new file mode 100644 index 00000000000..090d9ddf7ff --- /dev/null +++ b/tests/components/usage_prediction/test_common_control.py @@ -0,0 +1,412 @@ +"""Test the common control usage prediction.""" + +from __future__ import annotations + +from unittest.mock import patch +import uuid + +from freezegun import freeze_time +import pytest + +from homeassistant.components.usage_prediction.common_control import ( + async_predict_common_control, + time_category, +) +from homeassistant.components.usage_prediction.models import EntityUsagePredictions +from homeassistant.const import EVENT_CALL_SERVICE +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.components.recorder.common import async_wait_recording_done + + +def test_time_category() -> None: + """Test the time category calculation logic.""" + for hour in range(6): + assert time_category(hour) == "night", hour + for hour in range(7, 12): + assert time_category(hour) == "morning", hour + for hour in range(13, 18): + assert time_category(hour) == "afternoon", hour + for hour in range(19, 22): + assert time_category(hour) == "evening", hour + + +@pytest.mark.usefixtures("recorder_mock") +async def test_empty_database(hass: HomeAssistant) -> None: + """Test function with empty database returns empty results.""" + user_id = str(uuid.uuid4()) + + # Call the function with empty database + results = await async_predict_common_control(hass, user_id) + + # Should return empty lists for all time categories + assert results == EntityUsagePredictions( + morning=[], + afternoon=[], + evening=[], + night=[], + ) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_invalid_user_id(hass: HomeAssistant) -> None: + """Test function with invalid user ID returns empty results.""" + # Invalid user ID format (not a valid UUID) + with pytest.raises(ValueError, match=r"Invalid user_id format"): + await async_predict_common_control(hass, "invalid-user-id") + + +@pytest.mark.usefixtures("recorder_mock") +async def test_with_service_calls(hass: HomeAssistant) -> None: + """Test function with actual service call events in database.""" + user_id = str(uuid.uuid4()) + + hass.states.async_set("light.living_room", "off") + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("climate.thermostat", "off") + hass.states.async_set("light.bedroom", "off") + hass.states.async_set("lock.front_door", "locked") + + # Create service call events at different times of day + # Morning events - use separate service calls to get around context deduplication + with freeze_time("2023-07-01 07:00:00"): # Morning + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": ["light.living_room", "light.kitchen"]}, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + # Afternoon events + with freeze_time("2023-07-01 14:00:00"): # Afternoon + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "climate", + "service": "set_temperature", + "service_data": {"entity_id": "climate.thermostat"}, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + # Evening events + with freeze_time("2023-07-01 19:00:00"): # Evening + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_off", + "service_data": {"entity_id": "light.bedroom"}, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + # Night events + with freeze_time("2023-07-01 23:00:00"): # Night + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "lock", + "service": "lock", + "service_data": {"entity_id": "lock.front_door"}, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + # Wait for events to be recorded + await async_wait_recording_done(hass) + + # Get predictions - make sure we're still in a reasonable timeframe + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent + results = await async_predict_common_control(hass, user_id) + + # Verify results contain the expected entities in the correct time periods + assert results == EntityUsagePredictions( + morning=["climate.thermostat"], + afternoon=["light.bedroom", "lock.front_door"], + evening=[], + night=["light.living_room", "light.kitchen"], + ) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: + """Test handling of service calls with multiple entity IDs.""" + user_id = str(uuid.uuid4()) + + ent_reg = er.async_get(hass) + ent_reg.async_get_or_create( + "light", + "test", + "living_room", + suggested_object_id="living_room", + hidden_by=er.RegistryEntryHider.USER, + ) + ent_reg.async_get_or_create( + "light", + "test", + "kitchen", + suggested_object_id="kitchen", + ) + + hass.states.async_set("light.living_room", "off") + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("light.hallway", "off") + hass.states.async_set("not_allowed.domain", "off") + + with freeze_time("2023-07-01 10:00:00"): # Morning + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_on", + "service_data": { + "entity_id": [ + "light.living_room", + "light.kitchen", + "light.hallway", + "not_allowed.domain", + "light.not_in_state_machine", + ] + }, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent + results = await async_predict_common_control(hass, user_id) + + # Two lights should be counted (10:00 UTC = 02:00 local = night) + # Living room is hidden via entity registry + assert results.night == ["light.kitchen", "light.hallway"] + assert results.morning == [] + assert results.afternoon == [] + assert results.evening == [] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_context_deduplication(hass: HomeAssistant) -> None: + """Test that multiple events with the same context are deduplicated.""" + user_id = str(uuid.uuid4()) + context = Context(user_id=user_id) + + hass.states.async_set("light.living_room", "off") + hass.states.async_set("switch.coffee_maker", "off") + + with freeze_time("2023-07-01 10:00:00"): # Morning + # Fire multiple events with the same context + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.living_room"}, + }, + context=context, + ) + await hass.async_block_till_done() + + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "switch", + "service": "turn_on", + "service_data": {"entity_id": "switch.coffee_maker"}, + }, + context=context, # Same context + ) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent + results = await async_predict_common_control(hass, user_id) + + # Only the first event should be processed (10:00 UTC = 02:00 local = night) + assert results == EntityUsagePredictions( + morning=[], + afternoon=[], + evening=[], + night=["light.living_room"], + ) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_old_events_excluded(hass: HomeAssistant) -> None: + """Test that events older than 30 days are excluded.""" + user_id = str(uuid.uuid4()) + + hass.states.async_set("light.old_event", "off") + hass.states.async_set("light.recent_event", "off") + + # Create an old event (35 days ago) + with freeze_time("2023-05-27 10:00:00"): # 35 days before July 1st + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.old_event"}, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + # Create a recent event (5 days ago) + with freeze_time("2023-06-26 10:00:00"): # 5 days before July 1st + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.recent_event"}, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + # Query with current time + with freeze_time("2023-07-01 10:00:00"): + results = await async_predict_common_control(hass, user_id) + + # Only recent event should be included (10:00 UTC = 02:00 local = night) + assert results == EntityUsagePredictions( + morning=[], + afternoon=[], + evening=[], + night=["light.recent_event"], + ) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_entities_limit(hass: HomeAssistant) -> None: + """Test that only top entities are returned per time category.""" + user_id = str(uuid.uuid4()) + + hass.states.async_set("light.most_used", "off") + hass.states.async_set("light.second", "off") + hass.states.async_set("light.third", "off") + hass.states.async_set("light.fourth", "off") + hass.states.async_set("light.fifth", "off") + hass.states.async_set("light.sixth", "off") + hass.states.async_set("light.seventh", "off") + + # Create more than 5 different entities in morning + with freeze_time("2023-07-01 08:00:00"): + # Create entities with different frequencies + entities_with_counts = [ + ("light.most_used", 10), + ("light.second", 8), + ("light.third", 6), + ("light.fourth", 4), + ("light.fifth", 2), + ("light.sixth", 1), + ("light.seventh", 1), + ] + + for entity_id, count in entities_with_counts: + for _ in range(count): + # Use different context for each call + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "toggle", + "service_data": {"entity_id": entity_id}, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + with ( + freeze_time("2023-07-02 10:00:00"), + patch( + "homeassistant.components.usage_prediction.common_control.RESULTS_TO_INCLUDE", + 5, + ), + ): # Next day, so events are recent + results = await async_predict_common_control(hass, user_id) + + # Should be the top 5 most used (08:00 UTC = 00:00 local = night) + assert results.night == [ + "light.most_used", + "light.second", + "light.third", + "light.fourth", + "light.fifth", + ] + assert results.morning == [] + assert results.afternoon == [] + assert results.evening == [] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_different_users_separated(hass: HomeAssistant) -> None: + """Test that events from different users are properly separated.""" + user_id_1 = str(uuid.uuid4()) + user_id_2 = str(uuid.uuid4()) + + hass.states.async_set("light.user1_light", "off") + hass.states.async_set("light.user2_light", "off") + + with freeze_time("2023-07-01 10:00:00"): + # User 1 events + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.user1_light"}, + }, + context=Context(user_id=user_id_1), + ) + await hass.async_block_till_done() + + # User 2 events + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.user2_light"}, + }, + context=Context(user_id=user_id_2), + ) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + # Get results for each user + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent + results_user1 = await async_predict_common_control(hass, user_id_1) + results_user2 = await async_predict_common_control(hass, user_id_2) + + # Each user should only see their own entities (10:00 UTC = 02:00 local = night) + assert results_user1 == EntityUsagePredictions( + morning=[], + afternoon=[], + evening=[], + night=["light.user1_light"], + ) + + assert results_user2 == EntityUsagePredictions( + morning=[], + afternoon=[], + evening=[], + night=["light.user2_light"], + ) diff --git a/tests/components/usage_prediction/test_init.py b/tests/components/usage_prediction/test_init.py new file mode 100644 index 00000000000..44c1ba32b55 --- /dev/null +++ b/tests/components/usage_prediction/test_init.py @@ -0,0 +1,63 @@ +"""Test usage_prediction integration.""" + +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components.usage_prediction import get_cached_common_control +from homeassistant.components.usage_prediction.models import EntityUsagePredictions +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.mark.usefixtures("recorder_mock") +async def test_usage_prediction_caching(hass: HomeAssistant) -> None: + """Test that usage prediction results are cached for 24 hours.""" + + assert await async_setup_component(hass, "usage_prediction", {}) + + finish_event = asyncio.Event() + + async def mock_common_control_error(*args) -> EntityUsagePredictions: + await finish_event.wait() + raise Exception("Boom") # noqa: TRY002 + + with patch( + "homeassistant.components.usage_prediction.common_control.async_predict_common_control", + mock_common_control_error, + ): + # First call, should trigger prediction + task1 = asyncio.create_task(get_cached_common_control(hass, "user_1")) + task2 = asyncio.create_task(get_cached_common_control(hass, "user_1")) + await asyncio.sleep(0) + finish_event.set() + with pytest.raises(Exception, match="Boom"): + await task2 + with pytest.raises(Exception, match="Boom"): + await task1 + + finish_event.clear() + results = EntityUsagePredictions( + morning=["light.kitchen"], + afternoon=["climate.thermostat"], + evening=["light.bedroom"], + night=["lock.front_door"], + ) + + # The exception is not cached, we hit the method again. + async def mock_common_control(*args) -> EntityUsagePredictions: + await finish_event.wait() + return results + + with patch( + "homeassistant.components.usage_prediction.common_control.async_predict_common_control", + mock_common_control, + ): + # First call, should trigger prediction + task1 = asyncio.create_task(get_cached_common_control(hass, "user_1")) + task2 = asyncio.create_task(get_cached_common_control(hass, "user_1")) + await asyncio.sleep(0) + finish_event.set() + assert await task2 is results + assert await task1 is results diff --git a/tests/components/usage_prediction/test_websocket.py b/tests/components/usage_prediction/test_websocket.py new file mode 100644 index 00000000000..d20999ed67b --- /dev/null +++ b/tests/components/usage_prediction/test_websocket.py @@ -0,0 +1,115 @@ +"""Test usage_prediction WebSocket API.""" + +from collections.abc import Generator +from copy import deepcopy +from datetime import datetime, timedelta +from unittest.mock import Mock, patch + +from freezegun import freeze_time +import pytest + +from homeassistant.components.usage_prediction.models import EntityUsagePredictions +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockUser +from tests.typing import WebSocketGenerator + +NOW = datetime(2026, 8, 26, 15, 0, 0, tzinfo=dt_util.UTC) + + +@pytest.fixture +def mock_predict_common_control() -> Generator[Mock]: + """Return a mock result for common control.""" + with patch( + "homeassistant.components.usage_prediction.common_control.async_predict_common_control", + return_value=EntityUsagePredictions( + morning=["light.kitchen"], + afternoon=["climate.thermostat"], + evening=["light.bedroom"], + night=["lock.front_door"], + ), + ) as mock_predict: + yield mock_predict + + +@pytest.mark.usefixtures("recorder_mock") +async def test_common_control( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, + mock_predict_common_control: Mock, +) -> None: + """Test usage_prediction common control WebSocket command.""" + assert await async_setup_component(hass, "usage_prediction", {}) + + client = await hass_ws_client(hass) + + with freeze_time(NOW): + await client.send_json({"id": 1, "type": "usage_prediction/common_control"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["type"] == "result" + assert msg["success"] is True + assert msg["result"] == { + "entities": [ + "light.kitchen", + ] + } + assert mock_predict_common_control.call_count == 1 + assert mock_predict_common_control.mock_calls[0][1][1] == hass_admin_user.id + + +@pytest.mark.usefixtures("recorder_mock") +async def test_caching_behavior( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_predict_common_control: Mock, +) -> None: + """Test that results are cached for 24 hours.""" + assert await async_setup_component(hass, "usage_prediction", {}) + + client = await hass_ws_client(hass) + + # First call should fetch from database + with freeze_time(NOW): + await client.send_json({"id": 1, "type": "usage_prediction/common_control"}) + msg = await client.receive_json() + + assert msg["success"] is True + assert msg["result"] == { + "entities": [ + "light.kitchen", + ] + } + assert mock_predict_common_control.call_count == 1 + + new_result = deepcopy(mock_predict_common_control.return_value) + new_result.morning.append("light.bla") + mock_predict_common_control.return_value = new_result + + # Second call within 24 hours should use cache + with freeze_time(NOW + timedelta(hours=23)): + await client.send_json({"id": 2, "type": "usage_prediction/common_control"}) + msg = await client.receive_json() + + assert msg["success"] is True + assert msg["result"] == { + "entities": [ + "light.kitchen", + ] + } + # Should still be 1 (no new database call) + assert mock_predict_common_control.call_count == 1 + + # Third call after 24 hours should fetch from database again + with freeze_time(NOW + timedelta(hours=25)): + await client.send_json({"id": 3, "type": "usage_prediction/common_control"}) + msg = await client.receive_json() + + assert msg["success"] is True + assert msg["result"] == {"entities": ["light.kitchen", "light.bla"]} + # Should now be 2 (new database call) + assert mock_predict_common_control.call_count == 2 diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 92fbca483fd..3fd7e525225 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -53,25 +53,6 @@ def test_all(module: ModuleType) -> None: help_test_all(module) -@pytest.mark.parametrize( - ("enum", "constant_prefix"), _create_tuples(vacuum.VacuumEntityFeature, "SUPPORT_") -) -@pytest.mark.parametrize( - "module", - [vacuum], -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, - module: ModuleType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, enum, constant_prefix, "2025.10" - ) - - @pytest.mark.parametrize( ("enum", "constant_prefix"), _create_tuples(vacuum.VacuumActivity, "STATE_") ) diff --git a/tests/components/vacuum/test_intent.py b/tests/components/vacuum/test_intent.py index 9ede7dbc04e..f3500d28653 100644 --- a/tests/components/vacuum/test_intent.py +++ b/tests/components/vacuum/test_intent.py @@ -4,9 +4,10 @@ from homeassistant.components.vacuum import ( DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, + VacuumEntityFeature, intent as vacuum_intent, ) -from homeassistant.const import STATE_IDLE +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_IDLE from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -18,7 +19,9 @@ async def test_start_vacuum_intent(hass: HomeAssistant) -> None: await vacuum_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_vacuum" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set( + entity_id, STATE_IDLE, {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.START} + ) calls = async_mock_service(hass, DOMAIN, SERVICE_START) response = await intent.async_handle( @@ -42,7 +45,9 @@ async def test_start_vacuum_without_name(hass: HomeAssistant) -> None: await vacuum_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_vacuum" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set( + entity_id, STATE_IDLE, {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.START} + ) calls = async_mock_service(hass, DOMAIN, SERVICE_START) response = await intent.async_handle( @@ -63,7 +68,11 @@ async def test_stop_vacuum_intent(hass: HomeAssistant) -> None: await vacuum_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_vacuum" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set( + entity_id, + STATE_IDLE, + {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.RETURN_HOME}, + ) calls = async_mock_service(hass, DOMAIN, SERVICE_RETURN_TO_BASE) response = await intent.async_handle( @@ -87,7 +96,11 @@ async def test_stop_vacuum_without_name(hass: HomeAssistant) -> None: await vacuum_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_vacuum" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set( + entity_id, + STATE_IDLE, + {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.RETURN_HOME}, + ) calls = async_mock_service(hass, DOMAIN, SERVICE_RETURN_TO_BASE) response = await intent.async_handle( diff --git a/tests/components/vegehub/conftest.py b/tests/components/vegehub/conftest.py index 6e48feb4271..6559de5de31 100644 --- a/tests/components/vegehub/conftest.py +++ b/tests/components/vegehub/conftest.py @@ -28,7 +28,7 @@ HUB_DATA = { "first_boot": False, "page_updated": False, "error_message": 0, - "num_channels": 2, + "num_channels": 4, "num_actuators": 2, "version": "3.4.5", "agenda": 1, @@ -41,7 +41,7 @@ HUB_DATA = { @pytest.fixture(autouse=True) -def mock_vegehub() -> Generator[Any, Any, Any]: +def mock_vegehub() -> Generator[Any]: """Mock the VegeHub library.""" with patch( "homeassistant.components.vegehub.config_flow.VegeHub", autospec=True @@ -57,7 +57,7 @@ def mock_vegehub() -> Generator[Any, Any, Any]: mock_instance.unique_id = TEST_UNIQUE_ID mock_instance.url = f"http://{TEST_IP}" mock_instance.info = load_fixture("vegehub/info_hub.json") - mock_instance.num_sensors = 2 + mock_instance.num_sensors = 4 mock_instance.num_actuators = 2 mock_instance.sw_version = "3.4.5" diff --git a/tests/components/vegehub/snapshots/test_sensor.ambr b/tests/components/vegehub/snapshots/test_sensor.ambr index 3a9a93dc03b..6fb0ef67c50 100644 --- a/tests/components/vegehub/snapshots/test_sensor.ambr +++ b/tests/components/vegehub/snapshots/test_sensor.ambr @@ -49,7 +49,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.330000043', + 'state': '9.314800262', }) # --- # name: test_sensor_entities[sensor.vegehub_input_1-entry] @@ -158,3 +158,109 @@ 'state': '1.45599997', }) # --- +# name: test_sensor_entities[sensor.vegehub_input_3-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': None, + 'entity_id': 'sensor.vegehub_input_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 3', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'analog_sensor', + 'unique_id': 'A1B2C3D4E5F6_analog_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Input 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_input_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.330000043', + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_4-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': None, + 'entity_id': 'sensor.vegehub_input_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 4', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'analog_sensor', + 'unique_id': 'A1B2C3D4E5F6_analog_3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Input 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_input_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.075999998', + }) +# --- diff --git a/tests/components/vegehub/snapshots/test_switch.ambr b/tests/components/vegehub/snapshots/test_switch.ambr new file mode 100644 index 00000000000..ea6d0f81791 --- /dev/null +++ b/tests/components/vegehub/snapshots/test_switch.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_switch_entities[switch.vegehub_actuator_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.vegehub_actuator_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Actuator 1', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'A1B2C3D4E5F6_actuator_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.vegehub_actuator_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'VegeHub Actuator 1', + }), + 'context': , + 'entity_id': 'switch.vegehub_actuator_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[switch.vegehub_actuator_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': 'switch', + 'entity_category': None, + 'entity_id': 'switch.vegehub_actuator_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Actuator 2', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'A1B2C3D4E5F6_actuator_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.vegehub_actuator_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'VegeHub Actuator 2', + }), + 'context': , + 'entity_id': 'switch.vegehub_actuator_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vegehub/test_config_flow.py b/tests/components/vegehub/test_config_flow.py index 1cf3924f72f..a6061a3a159 100644 --- a/tests/components/vegehub/test_config_flow.py +++ b/tests/components/vegehub/test_config_flow.py @@ -40,7 +40,7 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( @pytest.fixture(autouse=True) -def mock_setup_entry() -> Generator[Any, Any, Any]: +def mock_setup_entry() -> Generator[Any]: """Prevent the actual integration from being set up.""" with ( patch("homeassistant.components.vegehub.async_setup_entry", return_value=True), diff --git a/tests/components/vegehub/test_switch.py b/tests/components/vegehub/test_switch.py new file mode 100644 index 00000000000..ab9768b8149 --- /dev/null +++ b/tests/components/vegehub/test_switch.py @@ -0,0 +1,107 @@ +"""Unit tests for the VegeHub integration's switch.py.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration +from .conftest import TEST_SIMPLE_MAC, TEST_WEBHOOK_ID + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + +UPDATE_DATA = { + "api_key": "", + "mac": TEST_SIMPLE_MAC, + "error_code": 0, + "sensors": [ + {"slot": 1, "samples": [{"v": 1.5, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 2, "samples": [{"v": 1.45599997, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 3, "samples": [{"v": 1.330000043, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 4, "samples": [{"v": 0.075999998, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 5, "samples": [{"v": 9.314800262, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 6, "samples": [{"v": 1, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 7, "samples": [{"v": 0, "t": "2025-01-15T16:51:23Z"}]}, + ], + "send_time": 1736959883, + "wifi_str": -27, +} + + +async def test_switch_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client_no_auth: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test all entities.""" + + with patch("homeassistant.components.vegehub.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, mocked_config_entry) + + assert TEST_WEBHOOK_ID in hass.data["webhook"], "Webhook was not registered" + + # Verify the webhook handler + webhook_info = hass.data["webhook"][TEST_WEBHOOK_ID] + assert webhook_info["handler"], "Webhook handler is not set" + + client = await hass_client_no_auth() + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + + # Send the same update again so that the coordinator modifies existing data + # instead of creating new data. + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + assert resp.status == 200, f"Unexpected status code: {resp.status}" + await snapshot_platform( + hass, entity_registry, snapshot, mocked_config_entry.entry_id + ) + + +async def test_switch_turn_on_off( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test switch turn_on and turn_off methods.""" + with patch("homeassistant.components.vegehub.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, mocked_config_entry) + + # Send webhook data to initialize switches + client = await hass_client_no_auth() + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + await hass.async_block_till_done() + assert resp.status == 200 + + # Get switch entity IDs + switch_entity_ids = hass.states.async_entity_ids("switch") + assert len(switch_entity_ids) > 0, "No switch entities found" + + # Test turn_on method + with patch( + "homeassistant.components.vegehub.VegeHub.set_actuator" + ) as mock_set_actuator: + await hass.services.async_call( + "switch", "turn_on", {"entity_id": switch_entity_ids[0]}, blocking=True + ) + mock_set_actuator.assert_called_once_with( + 1, 0, 600 + ) # on, index 0, duration 600 + + # Test turn_off method + with patch( + "homeassistant.components.vegehub.VegeHub.set_actuator" + ) as mock_set_actuator: + await hass.services.async_call( + "switch", "turn_off", {"entity_id": switch_entity_ids[0]}, blocking=True + ) + mock_set_actuator.assert_called_once_with( + 0, 0, 600 + ) # off, index 0, duration 600 diff --git a/tests/components/velux/__init__.py b/tests/components/velux/__init__.py index 6cf5cd366fb..b50a46b1150 100644 --- a/tests/components/velux/__init__.py +++ b/tests/components/velux/__init__.py @@ -1 +1,31 @@ """Tests for the Velux integration.""" + +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.helpers.device_registry import HomeAssistant +from homeassistant.helpers.entity_platform import timedelta + +from tests.common import async_fire_time_changed + + +async def update_callback_entity( + hass: HomeAssistant, mock_velux_node: MagicMock +) -> None: + """Simulate an update triggered by the pyvlx lib for a Velux node.""" + + callback = mock_velux_node.register_device_updated_cb.call_args[0][0] + await callback(mock_velux_node) + await hass.async_block_till_done() + + +async def update_polled_entities( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Simulate an update trigger from polling.""" + # just fire a time changed event to trigger the polling + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 1b7066577ad..22fc1a93357 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -72,6 +72,9 @@ def mock_window() -> AsyncMock: window.rain_sensor = True window.serial_number = "123456789" window.get_limitation.return_value = MagicMock(min_value=0) + window.is_opening = False + window.is_closing = False + window.position = MagicMock(position_percent=30, closed=False) return window diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index 8eb065a5a46..b7048173a65 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -1,6 +1,5 @@ """Tests for the Velux binary sensor platform.""" -from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -8,8 +7,12 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry -from tests.common import MockConfigEntry, async_fire_time_changed +from . import update_polled_entities + +from tests.common import MockConfigEntry @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -21,30 +24,74 @@ async def test_rain_sensor_state( freezer: FrozenDateTimeFactory, ) -> None: """Test the rain sensor.""" + mock_config_entry.add_to_hass(hass) - - test_entity_id = "binary_sensor.test_window_rain_sensor" - - with ( - patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]), - ): + with patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]): # setup config entry assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + test_entity_id = "binary_sensor.test_window_rain_sensor" + # simulate no rain detected - freezer.tick(timedelta(minutes=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await update_polled_entities(hass, freezer) state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_OFF - # simulate rain detected - mock_window.get_limitation.return_value.min_value = 93 - freezer.tick(timedelta(minutes=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + # simulate rain detected (Velux GPU reports 100) + mock_window.get_limitation.return_value.min_value = 100 + await update_polled_entities(hass, freezer) state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_ON + + # simulate rain detected (other Velux models report 93) + mock_window.get_limitation.return_value.min_value = 93 + await update_polled_entities(hass, freezer) + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_ON + + # simulate no rain detected again + mock_window.get_limitation.return_value.min_value = 95 + await update_polled_entities(hass, freezer) + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OFF + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("mock_module") +async def test_rain_sensor_device_association( + hass: HomeAssistant, + mock_window: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, +) -> None: + """Test the rain sensor is properly associated with its device.""" + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + test_entity_id = "binary_sensor.test_window_rain_sensor" + + # Verify entity exists + state = hass.states.get(test_entity_id) + assert state is not None + + # Get entity entry + entity_entry = entity_registry.async_get(test_entity_id) + assert entity_entry is not None + assert entity_entry.device_id is not None + + # Get device entry + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry is not None + + # Verify device has correct identifiers + assert ("velux", mock_window.serial_number) in device_entry.identifiers + assert device_entry.name == mock_window.name diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py new file mode 100644 index 00000000000..621aa1c3b6c --- /dev/null +++ b/tests/components/velux/test_cover.py @@ -0,0 +1,48 @@ +"""Tests for the Velux cover platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import STATE_CLOSED, STATE_OPEN, Platform +from homeassistant.core import HomeAssistant + +from . import update_callback_entity + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_module") +async def test_cover_closed( + hass: HomeAssistant, + mock_window: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the cover closed state.""" + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.velux.PLATFORMS", [Platform.COVER]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + test_entity_id = "cover.test_window" + + # Initial state should be open + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OPEN + + # Update mock window position to closed percentage + mock_window.position.position_percent = 100 + # Also directly set position to closed, so this test should + # continue to be green after the lib is fixed + mock_window.position.closed = True + + # Trigger entity state update via registered callback + await update_callback_entity(hass, mock_window) + + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_CLOSED diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index cf2f49ff28f..dd80cf277a2 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -1,14 +1,12 @@ """Common methods used across tests for VeSync.""" -import json from typing import Any -import requests_mock - from homeassistant.components.vesync.const import DOMAIN from homeassistant.util.json import JsonObjectType -from tests.common import load_fixture, load_json_object_fixture +from tests.common import load_json_object_fixture +from tests.test_util.aiohttp import AiohttpClientMocker ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" @@ -19,55 +17,68 @@ ENTITY_FAN = "fan.SmartTowerFan" ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" +DEVICE_CATEGORIES = [ + "outlets", + "switches", + "fans", + "bulbs", + "humidifiers", + "air_purifiers", + "air_fryers", + "thermostats", +] + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] ] DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { "Humidifier 200s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-200s.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-detail.json") ], "Humidifier 600S": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-detail.json") ], "Air Purifier 131s": [ ( "post", - "/131airPurifier/v1/device/deviceDetail", + "/cloud/v1/deviceManaged/deviceDetail", "air-purifier-131s-detail.json", ) ], "Air Purifier 200s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-detail.json") ], "Air Purifier 400s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-400s-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-detail.json") ], "Air Purifier 600s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-detail.json") ], "Dimmable Light": [ - ("post", "/SmartBulb/v1/device/devicedetail", "device-detail.json") + ("post", "/cloud/v1/deviceManaged/deviceDetail", "device-detail.json") ], "Temperature Light": [ - ("post", "/cloud/v1/deviceManaged/bypass", "device-detail.json") + ("post", "/cloud/v1/deviceManaged/bypass", "light-detail.json") ], "Outlet": [ ("get", "/v1/device/outlet/detail", "outlet-detail.json"), - ("get", "/v1/device/outlet/energy/week", "outlet-energy-week.json"), + ("post", "/cloud/v1/device/getLastWeekEnergy", "outlet-energy.json"), + ("post", "/cloud/v1/device/getLastMonthEnergy", "outlet-energy.json"), + ("post", "/cloud/v1/device/getLastYearEnergy", "outlet-energy.json"), ], "Wall Switch": [ - ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") + ("post", "/cloud/v1/deviceManaged/deviceDetail", "device-detail.json") ], - "Dimmer Switch": [("post", "/dimmer/v1/device/devicedetail", "dimmer-detail.json")], - "SmartTowerFan": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "SmartTowerFan-detail.json") + "Dimmer Switch": [ + ("post", "/cloud/v1/deviceManaged/deviceDetail", "dimmer-detail.json") ], + "SmartTowerFan": [("post", "/cloud/v2/deviceManaged/bypassV2", "fan-detail.json")], } def mock_devices_response( - requests_mock: requests_mock.Mocker, device_name: str + aioclient_mock: AiohttpClientMocker, device_name: str ) -> None: """Build a response for the Helpers.call_api method.""" device_list = [ @@ -76,24 +87,32 @@ def mock_devices_response( if device["deviceName"] == device_name ] - requests_mock.post( + aioclient_mock.post( "https://smartapi.vesync.com/cloud/v1/deviceManaged/devices", - json={"code": 0, "result": {"list": device_list}}, - ) - requests_mock.post( - "https://smartapi.vesync.com/cloud/v1/user/login", - json=load_json_object_fixture("vesync-login.json", DOMAIN), + json={ + "traceId": "1234", + "code": 0, + "msg": None, + "module": None, + "stacktrace": None, + "result": { + "total": len(device_list), + "pageSize": len(device_list), + "pageNo": 1, + "list": device_list, + }, + }, ) + for fixture in DEVICE_FIXTURES[device_name]: - requests_mock.request( - fixture[0], + getattr(aioclient_mock, fixture[0])( f"https://smartapi.vesync.com{fixture[1]}", json=load_json_object_fixture(fixture[2], DOMAIN), ) def mock_multiple_device_responses( - requests_mock: requests_mock.Mocker, device_names: list[str] + aioclient_mock: AiohttpClientMocker, device_names: list[str] ) -> None: """Build a response for the Helpers.call_api method for multiple devices.""" device_list = [ @@ -102,41 +121,39 @@ def mock_multiple_device_responses( if device["deviceName"] in device_names ] - requests_mock.post( + aioclient_mock.post( "https://smartapi.vesync.com/cloud/v1/deviceManaged/devices", - json={"code": 0, "result": {"list": device_list}}, - ) - requests_mock.post( - "https://smartapi.vesync.com/cloud/v1/user/login", - json=load_json_object_fixture("vesync-login.json", DOMAIN), + json={ + "traceId": "1234", + "code": 0, + "msg": None, + "module": None, + "stacktrace": None, + "result": { + "total": len(device_list), + "pageSize": len(device_list), + "pageNo": 1, + "list": device_list, + }, + }, ) + for device_name in device_names: - for fixture in DEVICE_FIXTURES[device_name]: - requests_mock.request( - fixture[0], - f"https://smartapi.vesync.com{fixture[1]}", - json=load_json_object_fixture(fixture[2], DOMAIN), - ) + fixture = DEVICE_FIXTURES[device_name][0] - -def mock_air_purifier_400s_update_response(requests_mock: requests_mock.Mocker) -> None: - """Build a response for the Helpers.call_api method for air_purifier_400s with updated data.""" - - device_name = "Air Purifier 400s" - for fixture in DEVICE_FIXTURES[device_name]: - requests_mock.request( - fixture[0], + getattr(aioclient_mock, fixture[0])( f"https://smartapi.vesync.com{fixture[1]}", - json=load_json_object_fixture( - "air-purifier-400s-detail-updated.json", DOMAIN - ), + json=load_json_object_fixture(fixture[2], DOMAIN), ) def mock_device_response( - requests_mock: requests_mock.Mocker, device_name: str, override: Any + aioclient_mock: AiohttpClientMocker, device_name: str, override: Any ) -> None: - """Build a response for the Helpers.call_api method with updated data.""" + """Build a response for the Helpers.call_api method with updated data. + + The provided override only applies to the base device response. + """ def load_and_merge(source: str) -> JsonObjectType: json = load_json_object_fixture(source, DOMAIN) @@ -152,15 +169,14 @@ def mock_device_response( if len(fixtures) > 0: item = fixtures[0] - requests_mock.request( - item[0], + getattr(aioclient_mock, item[0])( f"https://smartapi.vesync.com{item[1]}", json=load_and_merge(item[2]), ) def mock_outlet_energy_response( - requests_mock: requests_mock.Mocker, device_name: str, override: Any + aioclient_mock: AiohttpClientMocker, device_name: str, override: Any = None ) -> None: """Build a response for the Helpers.call_api energy request with updated data.""" @@ -168,83 +184,16 @@ def mock_outlet_energy_response( json = load_json_object_fixture(source, DOMAIN) if override: - json.update(override) + if "result" in json: + json["result"].update(override) + else: + json.update(override) return json - fixtures = DEVICE_FIXTURES[device_name] - - # The 2nd item contain energy details - if len(fixtures) > 1: - item = fixtures[1] - - requests_mock.request( - item[0], - f"https://smartapi.vesync.com{item[1]}", - json=load_and_merge(item[2]), + # Skip the device details (1st item) + for fixture in DEVICE_FIXTURES[device_name][1:]: + getattr(aioclient_mock, fixture[0])( + f"https://smartapi.vesync.com{fixture[1]}", + json=load_and_merge(fixture[2]), ) - - -def call_api_side_effect__no_devices(*args, **kwargs): - """Build a side_effects method for the Helpers.call_api method.""" - if args[0] == "/cloud/v1/user/login" and args[1] == "post": - return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": - return ( - json.loads( - load_fixture("vesync_api_call__devices__no_devices.json", "vesync") - ), - 200, - ) - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") - - -def call_api_side_effect__single_humidifier(*args, **kwargs): - """Build a side_effects method for the Helpers.call_api method.""" - if args[0] == "/cloud/v1/user/login" and args[1] == "post": - return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": - return ( - json.loads( - load_fixture( - "vesync_api_call__devices__single_humidifier.json", "vesync" - ) - ), - 200, - ) - if args[0] == "/cloud/v2/deviceManaged/bypassV2" and kwargs["method"] == "post": - return ( - json.loads( - load_fixture( - "vesync_api_call__device_details__single_humidifier.json", "vesync" - ) - ), - 200, - ) - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") - - -def call_api_side_effect__single_fan(*args, **kwargs): - """Build a side_effects method for the Helpers.call_api method.""" - if args[0] == "/cloud/v1/user/login" and args[1] == "post": - return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": - return ( - json.loads( - load_fixture("vesync_api_call__devices__single_fan.json", "vesync") - ), - 200, - ) - if ( - args[0] == "/131airPurifier/v1/device/deviceDetail" - and kwargs["method"] == "post" - ): - return ( - json.loads( - load_fixture( - "vesync_api_call__device_details__single_fan.json", "vesync" - ) - ), - 200, - ) - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 32f23101755..8b15e7c76d3 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -2,15 +2,22 @@ from __future__ import annotations -from unittest.mock import Mock, patch +from collections.abc import Iterator +from contextlib import ExitStack +from itertools import chain +from types import MappingProxyType +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import pytest from pyvesync import VeSync -from pyvesync.vesyncbulb import VeSyncBulb -from pyvesync.vesyncfan import VeSyncAirBypass, VeSyncHumid200300S -from pyvesync.vesyncoutlet import VeSyncOutlet -from pyvesync.vesyncswitch import VeSyncSwitch -import requests_mock +from pyvesync.auth import VeSyncAuth +from pyvesync.base_devices.bulb_base import VeSyncBulb +from pyvesync.base_devices.fan_base import VeSyncFanBase +from pyvesync.base_devices.humidifier_base import HumidifierState +from pyvesync.base_devices.outlet_base import VeSyncOutlet +from pyvesync.base_devices.switch_base import VeSyncSwitch +from pyvesync.const import HumidifierFeatures +from pyvesync.devices.vesynchumidifier import VeSyncHumid200S, VeSyncHumid200300S from homeassistant.components.vesync import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -18,9 +25,75 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from .common import mock_multiple_device_responses +from .common import DEVICE_CATEGORIES, mock_multiple_device_responses from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(autouse=True) +def patch_vesync_firmware(): + """Patch VeSync to disable firmware checks.""" + with patch( + "pyvesync.vesync.VeSync.check_firmware", new=AsyncMock(return_value=True) + ): + yield + + +@pytest.fixture(autouse=True) +def patch_vesync_login(): + """Patch VeSync login method.""" + with patch("pyvesync.vesync.VeSync.login", new=AsyncMock()): + yield + + +@pytest.fixture(autouse=True) +def patch_vesync(): + """Patch VeSync methods and several properties/attributes for all tests.""" + props = { + "enabled": True, + } + + with ( + patch.multiple( + "pyvesync.vesync.VeSync", + check_firmware=AsyncMock(return_value=True), + ), + ExitStack() as stack, + ): + for name, value in props.items(): + mock = stack.enter_context( + patch.object(VeSync, name, new_callable=PropertyMock) + ) + mock.return_value = value + yield + + +@pytest.fixture(autouse=True) +def patch_vesync_auth(): + """Patch VeSync Auth methods and several properties/attributes for all tests.""" + props = { + "_token": "TESTTOKEN", + "_account_id": "TESTACCOUNTID", + "_country_code": "US", + "_current_region": "US", + "_username": "TESTUSERNAME", + "_password": "TESTPASSWORD", + } + + with ( + patch.multiple( + "pyvesync.auth.VeSyncAuth", + login=AsyncMock(return_value=True), + ), + ExitStack() as stack, + ): + for name, value in props.items(): + mock = stack.enter_context( + patch.object(VeSyncAuth, name, new_callable=PropertyMock) + ) + mock.return_value = value + yield @pytest.fixture(name="config_entry") @@ -41,103 +114,134 @@ def config_fixture() -> ConfigType: return {DOMAIN: {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}} +class _DevicesContainer: + def __init__(self) -> None: + for category in DEVICE_CATEGORIES: + setattr(self, category, []) + + # wrap all devices in a read-only proxy array + self._devices = MappingProxyType( + {category: getattr(self, category) for category in DEVICE_CATEGORIES} + ) + + def __iter__(self) -> Iterator[_DevicesContainer]: + return chain.from_iterable(getattr(self, c) for c in DEVICE_CATEGORIES) + + def __len__(self) -> int: + return sum(len(getattr(self, c)) for c in DEVICE_CATEGORIES) + + def __bool__(self) -> bool: + return any(getattr(self, c) for c in DEVICE_CATEGORIES) + + @pytest.fixture(name="manager") -def manager_fixture() -> VeSync: +def manager_fixture(): """Create a mock VeSync manager fixture.""" + devices = _DevicesContainer() - outlets = [] - switches = [] - fans = [] - bulbs = [] + mock_vesync = MagicMock(spec=VeSync) + mock_vesync.update = AsyncMock() + mock_vesync.devices = devices + mock_vesync._dev_list = devices._devices - mock_vesync = Mock(VeSync) - mock_vesync.login = Mock(return_value=True) - mock_vesync.update = Mock() - mock_vesync.outlets = outlets - mock_vesync.switches = switches - mock_vesync.fans = fans - mock_vesync.bulbs = bulbs - mock_vesync._dev_list = { - "fans": fans, - "outlets": outlets, - "switches": switches, - "bulbs": bulbs, - } mock_vesync.account_id = "account_id" mock_vesync.time_zone = "America/New_York" - mock = Mock(return_value=mock_vesync) - with patch("homeassistant.components.vesync.VeSync", new=mock): + with patch("homeassistant.components.vesync.VeSync", return_value=mock_vesync): yield mock_vesync @pytest.fixture(name="fan") def fan_fixture(): """Create a mock VeSync fan fixture.""" - return Mock(VeSyncAirBypass) + return Mock( + VeSyncFanBase, + cid="fan", + device_type="fan", + device_name="Test Fan", + device_status="on", + modes=[], + connection_status="online", + current_firm_version="1.0.0", + ) @pytest.fixture(name="bulb") def bulb_fixture(): """Create a mock VeSync bulb fixture.""" - return Mock(VeSyncBulb) + return Mock( + VeSyncBulb, + cid="bulb", + device_name="Test Bulb", + ) @pytest.fixture(name="switch") def switch_fixture(): """Create a mock VeSync switch fixture.""" - mock_fixture = Mock(VeSyncSwitch) - mock_fixture.is_dimmable = Mock(return_value=False) - return mock_fixture + return Mock( + VeSyncSwitch, + is_dimmable=Mock(return_value=False), + ) @pytest.fixture(name="dimmable_switch") def dimmable_switch_fixture(): """Create a mock VeSync switch fixture.""" - mock_fixture = Mock(VeSyncSwitch) - mock_fixture.is_dimmable = Mock(return_value=True) - return mock_fixture + return Mock( + VeSyncSwitch, + is_dimmable=Mock(return_value=True), + ) @pytest.fixture(name="outlet") def outlet_fixture(): """Create a mock VeSync outlet fixture.""" - return Mock(VeSyncOutlet) + return Mock( + VeSyncOutlet, + cid="outlet", + device_name="Test Outlet", + ) @pytest.fixture(name="humidifier") def humidifier_fixture(): - """Create a mock VeSync Classic200S humidifier fixture.""" + """Create a mock VeSync Classic 200S humidifier fixture.""" return Mock( - VeSyncHumid200300S, + VeSyncHumid200S, cid="200s-humidifier", config={ "auto_target_humidity": 40, "display": "true", "automatic_stop": "true", }, - details={ - "humidity": 35, - "mode": "manual", - }, + features=[HumidifierFeatures.NIGHTLIGHT], device_type="Classic200S", device_name="Humidifier 200s", device_status="on", - mist_level=6, mist_modes=["auto", "manual"], - mode=None, + mist_levels=[1, 2, 3, 4, 5, 6], sub_device_no=0, - config_module="configModule", + target_minmax=(30, 80), + state=Mock( + HumidifierState, + connection_status="online", + humidity=50, + mist_level=6, + mode=None, + nightlight_status="dim", + nightlight_brightness=50, + water_lacks=False, + water_tank_lifted=False, + ), connection_status="online", current_firm_version="1.0.0", - water_lacks=False, - water_tank_lifted=False, ) @pytest.fixture(name="humidifier_300s") def humidifier_300s_fixture(): - """Create a mock VeSync Classic300S humidifier fixture.""" + """Create a mock VeSync Classic 300S humidifier fixture.""" return Mock( VeSyncHumid200300S, cid="300s-humidifier", @@ -146,26 +250,33 @@ def humidifier_300s_fixture(): "display": "true", "automatic_stop": "true", }, - details={"humidity": 35, "mode": "manual", "night_light_brightness": 50}, + features=[HumidifierFeatures.NIGHTLIGHT], device_type="Classic300S", device_name="Humidifier 300s", device_status="on", - mist_level=6, mist_modes=["auto", "manual"], - mode=None, - night_light=True, + mist_levels=[1, 2, 3, 4, 5, 6], sub_device_no=0, + target_minmax=(30, 80), + state=Mock( + HumidifierState, + connection_status="online", + humidity=50, + mist_level=6, + mode=None, + nightlight_status="dim", + nightlight_brightness=50, + water_lacks=False, + water_tank_lifted=False, + ), config_module="configModule", - connection_status="online", current_firm_version="1.0.0", - water_lacks=False, - water_tank_lifted=False, ) @pytest.fixture(name="humidifier_config_entry") async def humidifier_config_entry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, config + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config ) -> MockConfigEntry: """Create a mock VeSync config entry for `Humidifier 200s`.""" entry = MockConfigEntry( @@ -176,7 +287,7 @@ async def humidifier_config_entry( entry.add_to_hass(hass) device_name = "Humidifier 200s" - mock_multiple_device_responses(requests_mock, [device_name]) + mock_multiple_device_responses(aioclient_mock, [device_name]) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -193,14 +304,14 @@ async def install_humidifier_device( """Create a mock VeSync config entry with the specified humidifier device.""" # Install the defined humidifier - manager._dev_list["fans"].append(request.getfixturevalue(request.param)) + manager._dev_list["humidifiers"].append(request.getfixturevalue(request.param)) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @pytest.fixture(name="fan_config_entry") async def fan_config_entry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, config + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config ) -> MockConfigEntry: """Create a mock VeSync config entry for `SmartTowerFan`.""" entry = MockConfigEntry( @@ -211,7 +322,7 @@ async def fan_config_entry( entry.add_to_hass(hass) device_name = "SmartTowerFan" - mock_multiple_device_responses(requests_mock, [device_name]) + mock_multiple_device_responses(aioclient_mock, [device_name]) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -220,7 +331,7 @@ async def fan_config_entry( @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, config + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config ) -> MockConfigEntry: """Create a mock VeSync config entry for `switch` with the old unique ID approach.""" entry = MockConfigEntry( @@ -235,6 +346,6 @@ async def switch_old_id_config_entry( wall_switch = "Wall Switch" humidifer = "Humidifier 200s" - mock_multiple_device_responses(requests_mock, [wall_switch, humidifer]) + mock_multiple_device_responses(aioclient_mock, [wall_switch, humidifer]) return entry diff --git a/tests/components/vesync/fixtures/air-purifier-131s-detail.json b/tests/components/vesync/fixtures/air-purifier-131s-detail.json index a7598c621d3..80effb9b4e4 100644 --- a/tests/components/vesync/fixtures/air-purifier-131s-detail.json +++ b/tests/components/vesync/fixtures/air-purifier-131s-detail.json @@ -1,25 +1,29 @@ { "code": 0, + "traceId": "1234", "msg": "request success", - "traceId": "1744558015", - "screenStatus": "on", - "filterLife": { - "change": false, - "useHour": 3034, - "percent": 25 - }, - "activeTime": 0, - "timer": null, - "scheduleCount": 0, - "schedule": null, - "levelNew": 0, - "airQuality": "excellent", - "level": null, - "mode": "sleep", - "deviceName": "Levoit 131S Air Purifier", - "currentFirmVersion": "2.0.58", - "childLock": "off", - "deviceStatus": "on", - "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/airpurifier131_240.png", - "connectionStatus": "online" + "module": null, + "stacktrace": null, + "result": { + "screenStatus": "on", + "filterLife": { + "change": false, + "useHour": 0, + "percent": 25 + }, + "activeTime": 0, + "timer": null, + "scheduleCount": 0, + "schedule": null, + "levelNew": 0, + "airQuality": "excellent", + "level": null, + "mode": "sleep", + "deviceName": "Levoit 131S Air Purifier", + "currentFirmVersion": "2.0.58", + "childLock": "off", + "deviceStatus": "on", + "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/airpurifier131_240.png", + "connectionStatus": "online" + } } diff --git a/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json b/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json deleted file mode 100644 index b48eefba4c9..00000000000 --- a/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "code": 0, - "brightNess": "50", - "result": { - "light": { - "brightness": 50, - "colorTempe": 5400 - }, - "result": { - "brightness": 50, - "red": 178.5, - "green": 255, - "blue": 25.5, - "colorMode": "rgb", - "humidity": 35, - "mist_virtual_level": 6, - "mode": "manual", - "water_lacks": true, - "water_tank_lifted": true, - "automatic_stop_reach_target": true, - "night_light_brightness": 10, - "enabled": true, - "filter_life": 99, - "level": 1, - "display": true, - "display_forever": false, - "child_lock": false, - "night_light": "on", - "air_quality": 15, - "air_quality_value": 1, - "configuration": { - "auto_target_humidity": 40, - "display": true, - "automatic_stop": true - } - }, - "code": 0 - } -} diff --git a/tests/components/vesync/fixtures/air-purifier-400s-detail.json b/tests/components/vesync/fixtures/air-purifier-400s-detail.json deleted file mode 100644 index a26d9e2a975..00000000000 --- a/tests/components/vesync/fixtures/air-purifier-400s-detail.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "code": 0, - "brightNess": "50", - "result": { - "light": { - "brightness": 50, - "colorTempe": 5400 - }, - "result": { - "brightness": 50, - "red": 178.5, - "green": 255, - "blue": 25.5, - "colorMode": "rgb", - "humidity": 35, - "mist_virtual_level": 6, - "mode": "manual", - "water_lacks": true, - "water_tank_lifted": true, - "automatic_stop_reach_target": true, - "night_light_brightness": 10, - "enabled": true, - "filter_life": 99, - "level": 1, - "display": true, - "display_forever": false, - "child_lock": false, - "night_light": "off", - "air_quality": 5, - "air_quality_value": 1, - "configuration": { - "auto_target_humidity": 40, - "display": true, - "automatic_stop": true - } - }, - "code": 0 - } -} diff --git a/tests/components/vesync/fixtures/air-purifier-detail-updated.json b/tests/components/vesync/fixtures/air-purifier-detail-updated.json new file mode 100644 index 00000000000..fdb1ed9454b --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-detail-updated.json @@ -0,0 +1,25 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "code": 0, + "result": { + "enabled": true, + "filter_life": 95, + "mode": "manual", + "level": 1, + "device_error_code": 0, + "air_quality": 2, + "air_quality_value": 15, + "display": true, + "child_lock": false, + "configuration": { + "display": true, + "display_forever": true, + "auto_preference": null + }, + "night_light": "on" + } + } +} diff --git a/tests/components/vesync/fixtures/air-purifier-detail-v2.json b/tests/components/vesync/fixtures/air-purifier-detail-v2.json new file mode 100644 index 00000000000..8d88a753539 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-detail-v2.json @@ -0,0 +1,26 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "code": 0, + "result": { + "powerSwitch": 1, + "filterLifePercent": 99, + "workMode": "manual", + "manualSpeedLevel": 1, + "fanSpeedLevel": 0, + "AQLevel": 1, + "PM25": 5, + "screenState": 1, + "childLockSwitch": 0, + "screenSwitch": 1, + "lightDetectionSwitch": 0, + "environmentLightState": 1, + "scheduleCount": 0, + "timerRemain": 0, + "efficientModeTimeRemain": 0, + "errorCode": 0 + } + } +} diff --git a/tests/components/vesync/fixtures/air-purifier-detail.json b/tests/components/vesync/fixtures/air-purifier-detail.json new file mode 100644 index 00000000000..4340388ad24 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-detail.json @@ -0,0 +1,25 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "code": 0, + "result": { + "enabled": true, + "filter_life": 99, + "mode": "manual", + "level": 1, + "device_error_code": 0, + "air_quality": 1, + "air_quality_value": 5, + "display": true, + "child_lock": false, + "configuration": { + "display": true, + "display_forever": true, + "auto_preference": null + }, + "night_light": "off" + } + } +} diff --git a/tests/components/vesync/fixtures/device-detail.json b/tests/components/vesync/fixtures/device-detail.json index f0cb3033d4c..db6162a05bc 100644 --- a/tests/components/vesync/fixtures/device-detail.json +++ b/tests/components/vesync/fixtures/device-detail.json @@ -1,5 +1,7 @@ { "code": 0, + "msg": "request success", + "traceId": "1234", "brightNess": "50", "result": { "light": { @@ -24,6 +26,7 @@ "enabled": true, "filter_life": 99, "level": 1, + "device_error_code": 0, "display": true, "display_forever": false, "child_lock": false, @@ -37,6 +40,8 @@ "automatic_stop": true } }, - "code": 0 + "code": 0, + "traceId": "1234", + "msg": "" } } diff --git a/tests/components/vesync/fixtures/dimmer-detail.json b/tests/components/vesync/fixtures/dimmer-detail.json index 6da1b1baa57..4d432fe2b12 100644 --- a/tests/components/vesync/fixtures/dimmer-detail.json +++ b/tests/components/vesync/fixtures/dimmer-detail.json @@ -1,8 +1,19 @@ { "code": 0, - "deviceStatus": "on", - "activeTime": 100, - "brightness": 50, - "rgbStatus": "on", - "indicatorlightStatus": "on" + "msg": "request success", + "traceId": "1234", + "result": { + "devicename": "Test Dimmer", + "brightness": 50, + "indicatorlightStatus": "on", + "rgbStatus": "on", + "rgbValue": { + "red": 50, + "blue": 100, + "green": 225 + }, + "deviceStatus": "on", + "connectionStatus": "online", + "activeTime": 100 + } } diff --git a/tests/components/vesync/fixtures/SmartTowerFan-detail.json b/tests/components/vesync/fixtures/fan-detail.json similarity index 85% rename from tests/components/vesync/fixtures/SmartTowerFan-detail.json rename to tests/components/vesync/fixtures/fan-detail.json index 061dcb5b0d0..f7f07c1bd58 100644 --- a/tests/components/vesync/fixtures/SmartTowerFan-detail.json +++ b/tests/components/vesync/fixtures/fan-detail.json @@ -20,13 +20,10 @@ "muteState": 1, "timerRemain": 0, "temperature": 717, - "humidity": 40, - "thermalComfort": 65, "errorCode": 0, "sleepPreference": { - "sleepPreferenceType": "default", + "sleepPreferenceType": 0, "oscillationSwitch": 0, - "initFanSpeedLevel": 0, "fallAsleepRemain": 0, "autoChangeFanLevelSwitch": 0 }, diff --git a/tests/components/vesync/fixtures/humidifier-200s.json b/tests/components/vesync/fixtures/humidifier-detail.json similarity index 92% rename from tests/components/vesync/fixtures/humidifier-200s.json rename to tests/components/vesync/fixtures/humidifier-detail.json index a0a98bde110..09cf7a5bad8 100644 --- a/tests/components/vesync/fixtures/humidifier-200s.json +++ b/tests/components/vesync/fixtures/humidifier-detail.json @@ -1,5 +1,7 @@ { "code": 0, + "msg": "request success", + "traceId": "1234", "result": { "result": { "humidity": 35, diff --git a/tests/components/vesync/fixtures/light-detail.json b/tests/components/vesync/fixtures/light-detail.json new file mode 100644 index 00000000000..01baffec980 --- /dev/null +++ b/tests/components/vesync/fixtures/light-detail.json @@ -0,0 +1,12 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "light": { + "action": "on", + "brightness": 50, + "colorTempe": 50 + } + } +} diff --git a/tests/components/vesync/fixtures/outlet-energy-week.json b/tests/components/vesync/fixtures/outlet-energy-week.json deleted file mode 100644 index 6e23be2e197..00000000000 --- a/tests/components/vesync/fixtures/outlet-energy-week.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "energyConsumptionOfToday": 1, - "costPerKWH": 0.15, - "maxEnergy": 6, - "totalEnergy": 0, - "currency": "$" -} diff --git a/tests/components/vesync/fixtures/outlet-energy.json b/tests/components/vesync/fixtures/outlet-energy.json new file mode 100644 index 00000000000..336c4283643 --- /dev/null +++ b/tests/components/vesync/fixtures/outlet-energy.json @@ -0,0 +1,12 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "energyConsumptionOfToday": 1, + "costPerKWH": 0.15, + "maxEnergy": 6, + "totalEnergy": 0, + "energyInfos": [] + } +} diff --git a/tests/components/vesync/fixtures/vesync-auth.json b/tests/components/vesync/fixtures/vesync-auth.json new file mode 100644 index 00000000000..dd962878e65 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync-auth.json @@ -0,0 +1,9 @@ +{ + "code": 0, + "traceId": "1234", + "msg": null, + "result": { + "accountID": "1234", + "authorizeCode": "test-code" + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index 3109fd3ea40..7fbc9b03e3b 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -1,121 +1,189 @@ { "code": 0, + "traceId": "1234", + "msg": null, "result": { "list": [ { + "deviceRegion": "US", + "isOwner": true, "cid": "200s-humidifier", "deviceType": "Classic200S", "deviceName": "Humidifier 200s", + "deviceImg": "", + "type": "", + "connectionType": "", + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "configModule", "subDeviceNo": 4321, "deviceStatus": "on", + "connectionStatus": "online" + }, + { + "deviceRegion": "US", + "isOwner": true, + "cid": "600s-humidifier", + "deviceType": "LUH-A602S-WUS", + "deviceName": "Humidifier 600S", + "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", + "type": "", + "connectionType": "", + "subDeviceNo": null, + "deviceStatus": "off", + "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-555555555555", + "configModule": "WFON_AHM_LUH-A602S-WUS_US", + "currentFirmVersion": null, + "subDeviceType": null + }, + { + "deviceRegion": "US", + "isOwner": true, + "cid": "air-purifier", + "deviceType": "LV-PUR131S", + "deviceName": "Air Purifier 131s", + "deviceImg": "", + "type": "", + "connectionType": "", + "subDeviceNo": null, + "deviceStatus": "on", "connectionStatus": "online", "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { - "cid": "600s-humidifier", - "deviceType": "LUH-A602S-WUS", - "deviceName": "Humidifier 600S", - "subDeviceNo": null, - "deviceStatus": "off", - "connectionStatus": "online", - "uuid": "00000000-1111-2222-3333-555555555555", - "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", - "configModule": "WFON_AHM_LUH-A602S-WUS_US", - "currentFirmVersion": null, - "subDeviceType": null - }, - { - "cid": "air-purifier", - "deviceType": "LV-PUR131S", - "deviceName": "Air Purifier 131s", - "subDeviceNo": null, - "deviceStatus": "on", - "connectionStatus": "online", - "configModule": "configModule" - }, - { + "deviceRegion": "US", + "isOwner": true, "cid": "asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55", "deviceType": "Core200S", "deviceName": "Air Purifier 200s", "subDeviceNo": null, "deviceStatus": "on", + "deviceImg": "", "type": "wifi-air", + "connectionType": "", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "400s-purifier", "deviceType": "LAP-C401S-WJP", "deviceName": "Air Purifier 400s", + "deviceImg": "", "subDeviceNo": null, - "deviceStatus": "on", "type": "wifi-air", + "connectionType": "", + "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "600s-purifier", "deviceType": "LAP-C601S-WUS", "deviceName": "Air Purifier 600s", + "deviceImg": "", "subDeviceNo": null, "type": "wifi-air", + "connectionType": "", "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "dimmable-bulb", "deviceType": "ESL100", "deviceName": "Dimmable Light", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "tunable-bulb", "deviceType": "ESL100CW", "deviceName": "Temperature Light", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "outlet", "deviceType": "wifi-switch-1.3", "deviceName": "Outlet", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "switch", "deviceType": "ESWL01", "deviceName": "Wall Switch", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "dimmable-switch", "deviceType": "ESWD16", "deviceName": "Dimmer Switch", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "smarttowerfan", "deviceType": "LTF-F422S-KEU", "deviceName": "SmartTowerFan", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" } ] diff --git a/tests/components/vesync/fixtures/vesync-login.json b/tests/components/vesync/fixtures/vesync-login.json index 08139034738..655c752d94b 100644 --- a/tests/components/vesync/fixtures/vesync-login.json +++ b/tests/components/vesync/fixtures/vesync-login.json @@ -1,7 +1,11 @@ { "code": 0, + "traceId": "1234", + "msg": null, "result": { + "accountID": "1234", "token": "test-token", - "accountID": "1234" + "acceptLanguage": "en", + "countryCode": "US" } } diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json deleted file mode 100644 index 35b5a02fb3d..00000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "module": null, - "stacktrace": null, - "result": { - "traceId": "0000000000", - "code": 0, - "result": { - "enabled": false, - "mode": "humidity" - } - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json deleted file mode 100644 index f9e4b0e18f1..00000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "module": null, - "stacktrace": null, - "result": { - "traceId": "0000000000", - "code": 0, - "result": { - "enabled": false, - "mist_virtual_level": 9, - "mist_level": 3, - "mode": "humidity", - "water_lacks": false, - "water_tank_lifted": false, - "humidity": 35, - "humidity_high": false, - "display": false, - "warm_enabled": false, - "warm_level": 0, - "automatic_stop_reach_target": true, - "configuration": { "auto_target_humidity": 60, "display": true }, - "extension": { "schedule_count": 0, "timer_remain": 0 } - } - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json b/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json deleted file mode 100644 index f1eaa523101..00000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "result": { - "total": 1, - "pageSize": 100, - "pageNo": 1, - "list": [] - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json deleted file mode 100644 index 2951ab63f03..00000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "result": { - "total": 1, - "pageSize": 100, - "pageNo": 1, - "list": [ - { - "deviceRegion": "US", - "isOwner": true, - "authKey": null, - "deviceName": "Fan", - "deviceImg": "", - "cid": "abcdefghabcdefghabcdefghabcdefgh", - "deviceStatus": "off", - "connectionStatus": "online", - "connectionType": "WiFi+BTOnboarding+BTNotify", - "deviceType": "LV-PUR131S", - "type": "wifi-air", - "uuid": "00000000-1111-2222-3333-444444444444", - "configModule": "WFON_AHM_LV-PUR131S_US", - "macID": "00:00:00:00:00:00", - "mode": null, - "speed": null, - "currentFirmVersion": null, - "subDeviceNo": null, - "subDeviceType": null, - "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", - "subDeviceList": null, - "extension": null, - "deviceProp": null - } - ] - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json deleted file mode 100644 index 0f043394402..00000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "result": { - "total": 1, - "pageSize": 100, - "pageNo": 1, - "list": [ - { - "deviceRegion": "US", - "isOwner": true, - "authKey": null, - "deviceName": "Humidifier", - "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", - "cid": "abcdefghabcdefghabcdefghabcdefgh", - "deviceStatus": "off", - "connectionStatus": "online", - "connectionType": "WiFi+BTOnboarding+BTNotify", - "deviceType": "LUH-A602S-WUS", - "type": "wifi-air", - "uuid": "00000000-1111-2222-3333-444444444444", - "configModule": "WFON_AHM_LUH-A602S-WUS_US", - "macID": "00:00:00:00:00:00", - "mode": null, - "speed": null, - "currentFirmVersion": null, - "subDeviceNo": null, - "subDeviceType": null, - "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", - "subDeviceList": null, - "extension": null, - "deviceProp": null - } - ] - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__login.json b/tests/components/vesync/fixtures/vesync_api_call__login.json deleted file mode 100644 index 4a956f67341..00000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__login.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "result": { - "accountID": "9999999", - "token": "TOKEN" - } -} diff --git a/tests/components/vesync/snapshots/test_binary_sensor.ambr b/tests/components/vesync/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..3b851ea989c --- /dev/null +++ b/tests/components/vesync/snapshots/test_binary_sensor.ambr @@ -0,0 +1,633 @@ +# serializer version: 1 +# name: test_sensor_state[Air Purifier 131s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'air-purifier', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LV-PUR131S', + 'model_id': None, + 'name': 'Air Purifier 131s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 131s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Air Purifier 200s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Core200S', + 'model_id': None, + 'name': 'Air Purifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 200s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Air Purifier 400s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '400s-purifier', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LAP-C401S-WJP', + 'model_id': None, + 'name': 'Air Purifier 400s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 400s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Air Purifier 600s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-purifier', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LAP-C601S-WUS', + 'model_id': None, + 'name': 'Air Purifier 600s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 600s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Dimmable Light][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'dimmable-bulb', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'ESL100', + 'model_id': None, + 'name': 'Dimmable Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Dimmable Light][entities] + list([ + ]) +# --- +# name: test_sensor_state[Dimmer Switch][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'dimmable-switch', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'ESWD16', + 'model_id': None, + 'name': 'Dimmer Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Dimmer Switch][entities] + list([ + ]) +# --- +# name: test_sensor_state[Humidifier 200s][binary_sensor.humidifier_200s_low_water] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Humidifier 200s Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.humidifier_200s_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_state[Humidifier 200s][binary_sensor.humidifier_200s_water_tank_lifted] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Humidifier 200s Water tank lifted', + }), + 'context': , + 'entity_id': 'binary_sensor.humidifier_200s_water_tank_lifted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_state[Humidifier 200s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier4321', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Humidifier 200s][entities] + list([ + 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': None, + 'entity_id': 'binary_sensor.humidifier_200s_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_lacks', + 'unique_id': '200s-humidifier4321-water_lacks', + 'unit_of_measurement': None, + }), + 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': None, + 'entity_id': 'binary_sensor.humidifier_200s_water_tank_lifted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water tank lifted', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_tank_lifted', + 'unique_id': '200s-humidifier4321-details.water_tank_lifted', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_state[Humidifier 600S][binary_sensor.humidifier_600s_low_water] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Humidifier 600S Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.humidifier_600s_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_state[Humidifier 600S][binary_sensor.humidifier_600s_water_tank_lifted] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Humidifier 600S Water tank lifted', + }), + 'context': , + 'entity_id': 'binary_sensor.humidifier_600s_water_tank_lifted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_state[Humidifier 600S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Humidifier 600S][entities] + list([ + 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': None, + 'entity_id': 'binary_sensor.humidifier_600s_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_lacks', + 'unique_id': '600s-humidifier-water_lacks', + 'unit_of_measurement': None, + }), + 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': None, + 'entity_id': 'binary_sensor.humidifier_600s_water_tank_lifted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water tank lifted', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_tank_lifted', + 'unique_id': '600s-humidifier-details.water_tank_lifted', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_state[Outlet][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'outlet', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'wifi-switch-1.3', + 'model_id': None, + 'name': 'Outlet', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Outlet][entities] + list([ + ]) +# --- +# name: test_sensor_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[SmartTowerFan][entities] + list([ + ]) +# --- +# name: test_sensor_state[Temperature Light][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'tunable-bulb', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'ESL100CW', + 'model_id': None, + 'name': 'Temperature Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Temperature Light][entities] + list([ + ]) +# --- +# name: test_sensor_state[Wall Switch][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'switch', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'ESWL01', + 'model_id': None, + 'name': 'Wall Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Wall Switch][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index aa55a9be3cb..3f01ce765b9 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -1,223 +1,245 @@ # serializer version: 1 # name: test_async_get_config_entry_diagnostics__no_devices dict({ - 'devices': dict({ - 'bulbs': list([ - ]), - 'fans': list([ - ]), - 'outlets': list([ - ]), - 'switches': list([ - ]), - }), + 'devices': list([ + ]), 'vesync': dict({ + 'Total Device Count': 0, + 'air_purifiers': 0, 'bulb_count': 0, 'fan_count': 0, + 'humidifers_count': 0, 'outlets_count': 0, 'switch_count': 0, - 'timezone': 'US/Pacific', + 'timezone': 'America/New_York', }), }) # --- # name: test_async_get_config_entry_diagnostics__single_humidifier dict({ - 'devices': dict({ - 'bulbs': list([ - ]), - 'fans': list([ - dict({ - '_api_modes': list([ - 'getHumidifierStatus', - 'setAutomaticStop', - 'setSwitch', - 'setNightLightBrightness', - 'setVirtualLevel', - 'setTargetHumidity', - 'setHumidityMode', - 'setDisplay', - 'setLevel', - ]), - '_config_dict': dict({ - 'features': list([ - 'warm_mist', - 'nightlight', - ]), - 'mist_levels': list([ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - ]), - 'mist_modes': list([ - 'humidity', - 'sleep', - 'manual', - ]), - 'models': list([ - 'LUH-A602S-WUSR', - 'LUH-A602S-WUS', - 'LUH-A602S-WEUR', - 'LUH-A602S-WEU', - 'LUH-A602S-WJP', - 'LUH-A602S-WUSC', - ]), - 'module': 'VeSyncHumid200300S', - 'warm_mist_levels': list([ - 0, - 1, - 2, - 3, - ]), - }), - '_features': list([ - 'warm_mist', - 'nightlight', - ]), - 'cid': 'abcdefghabcdefghabcdefghabcdefgh', - 'config': dict({ - 'auto_target_humidity': 60, - 'automatic_stop': True, - 'display': True, - }), - 'config_module': 'WFON_AHM_LUH-A602S-WUS_US', - 'connection_status': 'online', - 'connection_type': 'WiFi+BTOnboarding+BTNotify', - 'current_firm_version': None, - 'details': dict({ - 'automatic_stop_reach_target': True, - 'display': False, - 'humidity': 35, - 'humidity_high': False, - 'mist_level': 3, - 'mist_virtual_level': 9, - 'mode': 'humidity', - 'night_light_brightness': 0, - 'warm_mist_enabled': False, - 'warm_mist_level': 0, - 'water_lacks': False, - 'water_tank_lifted': False, - }), - 'device_image': 'https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png', - 'device_name': 'Humidifier', - 'device_region': 'US', - 'device_status': 'off', - 'device_type': 'LUH-A602S-WUS', - 'enabled': False, - 'extension': None, - 'mac_id': '**REDACTED**', - 'manager': '**REDACTED**', - 'mist_levels': list([ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - ]), - 'mist_modes': list([ - 'humidity', - 'sleep', - 'manual', - ]), - 'mode': 'humidity', - 'night_light': True, - 'pid': None, - 'speed': None, - 'sub_device_no': None, - 'type': 'wifi-air', - 'uuid': '**REDACTED**', - 'warm_mist_feature': True, - 'warm_mist_levels': list([ - 0, - 1, - 2, - 3, - ]), + 'devices': list([ + dict({ + 'assert_any_call': 'Method', + 'assert_called': 'Method', + 'assert_called_once': 'Method', + 'assert_called_once_with': 'Method', + 'assert_called_with': 'Method', + 'assert_has_calls': 'Method', + 'assert_not_called': 'Method', + 'attach_mock': 'Method', + 'automatic_stop_off': 'Method', + 'automatic_stop_on': 'Method', + 'call_args': None, + 'call_args_list': list([ + ]), + 'call_bypassv2_api': 'Method', + 'call_count': 0, + 'called': False, + 'cid': '200s-humidifier', + 'clear_timer': 'Method', + 'config': dict({ + 'auto_target_humidity': 40, + 'automatic_stop': 'true', + 'display': 'true', }), - ]), - 'outlets': list([ - ]), - 'switches': list([ - ]), - }), + 'config_module': 'Method', + 'configure_mock': 'Method', + 'connection_status': 'online', + 'connection_type': 'Method', + 'current_firm_version': '1.0.0', + 'device_image': 'Method', + 'device_name': 'Humidifier 200s', + 'device_region': 'Method', + 'device_status': 'on', + 'device_type': 'Classic200S', + 'display': 'Method', + 'enabled': 'Method', + 'features': list([ + 'night_light', + ]), + 'firmware_update': 'Method', + 'get_details': 'Method', + 'get_state': 'Method', + 'get_timer': 'Method', + 'is_on': 'Method', + 'last_response': 'Method', + 'latest_firm_version': 'Method', + 'mac_id': 'Method', + 'manager': 'Method', + 'method_calls': list([ + ]), + 'mist_levels': list([ + 1, + 2, + 3, + 4, + 5, + 6, + ]), + 'mist_modes': list([ + 'auto', + 'manual', + ]), + 'mock_add_spec': 'Method', + 'mock_calls': list([ + ]), + 'pid': 'Method', + 'product_type': 'Method', + 'request_keys': 'Method', + 'reset_mock': 'Method', + 'return_value': 'Method', + 'set_auto_mode': 'Method', + 'set_automatic_stop': 'Method', + 'set_display': 'Method', + 'set_humidity': 'Method', + 'set_humidity_mode': 'Method', + 'set_manual_mode': 'Method', + 'set_mist_level': 'Method', + 'set_mode': 'Method', + 'set_nightlight_brightness': 'Method', + 'set_sleep_mode': 'Method', + 'set_state': 'Method', + 'set_timer': 'Method', + 'set_warm_level': 'Method', + 'side_effect': None, + 'state': 'Method', + 'sub_device_no': 0, + 'supports_drying_mode': 'Method', + 'supports_nightlight': 'Method', + 'supports_nightlight_brightness': 'Method', + 'supports_warm_mist': 'Method', + 'target_minmax': list([ + 30, + 80, + ]), + 'to_dict': 'Method', + 'to_json': 'Method', + 'to_jsonb': 'Method', + 'toggle_automatic_stop': 'Method', + 'toggle_display': 'Method', + 'toggle_drying_mode': 'Method', + 'toggle_switch': 'Method', + 'turn_off': 'Method', + 'turn_off_automatic_stop': 'Method', + 'turn_off_display': 'Method', + 'turn_on': 'Method', + 'turn_on_automatic_stop': 'Method', + 'turn_on_display': 'Method', + 'type': 'Method', + 'update': 'Method', + 'uuid': 'Method', + 'warm_mist_levels': 'Method', + }), + ]), 'vesync': dict({ + 'Total Device Count': 1, + 'air_purifiers': 0, 'bulb_count': 0, - 'fan_count': 1, + 'fan_count': 0, + 'humidifers_count': 1, 'outlets_count': 0, 'switch_count': 0, - 'timezone': 'US/Pacific', + 'timezone': 'America/New_York', }), }) # --- # name: test_async_get_device_diagnostics__single_fan dict({ - '_config_dict': dict({ - 'features': list([ - 'air_quality', - ]), - 'levels': list([ - 1, - 2, - ]), - 'models': list([ - 'LV-PUR131S', - 'LV-RH131S', - 'LV-RH131S-WM', - ]), - 'modes': list([ - 'manual', - 'auto', - 'sleep', - 'off', - ]), - 'module': 'VeSyncAir131', - }), - '_features': list([ - 'air_quality', + 'advanced_sleep_mode': 'Method', + 'assert_any_call': 'Method', + 'assert_called': 'Method', + 'assert_called_once': 'Method', + 'assert_called_once_with': 'Method', + 'assert_called_with': 'Method', + 'assert_has_calls': 'Method', + 'assert_not_called': 'Method', + 'attach_mock': 'Method', + 'call_args': None, + 'call_args_list': list([ ]), - 'air_quality_feature': True, - 'cid': 'abcdefghabcdefghabcdefghabcdefgh', - 'config': dict({ - }), - 'config_module': 'WFON_AHM_LV-PUR131S_US', - 'connection_status': 'unknown', - 'connection_type': 'WiFi+BTOnboarding+BTNotify', - 'current_firm_version': None, - 'details': dict({ - 'active_time': 0, - 'air_quality': 'unknown', - 'filter_life': dict({ - }), - 'level': 0, - 'screen_status': 'unknown', - }), - 'device_image': '', - 'device_name': 'Fan', - 'device_region': 'US', - 'device_status': 'unknown', - 'device_type': 'LV-PUR131S', - 'enabled': True, - 'extension': None, + 'call_count': 0, + 'called': False, + 'cid': 'fan', + 'clear_timer': 'Method', + 'config_module': 'Method', + 'configure_mock': 'Method', + 'connection_status': 'online', + 'connection_type': 'Method', + 'current_firm_version': '1.0.0', + 'device_image': 'Method', + 'device_name': 'Test Fan', + 'device_region': 'Method', + 'device_status': 'on', + 'device_type': 'fan', + 'display': 'Method', + 'enabled': 'Method', + 'fan_levels': 'Method', + 'features': 'Method', + 'firmware_update': 'Method', + 'get_details': 'Method', + 'get_state': 'Method', + 'get_timer': 'Method', 'home_assistant': dict({ 'disabled': False, 'disabled_by': None, 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_fan_low_water', + 'icon': None, + 'name': None, + 'original_device_class': 'problem', + 'original_icon': None, + 'original_name': 'Low water', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'problem', + 'friendly_name': 'Test Fan Low water', + }), + 'entity_id': 'binary_sensor.test_fan_low_water', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_fan_water_tank_lifted', + 'icon': None, + 'name': None, + 'original_device_class': 'problem', + 'original_icon': None, + 'original_name': 'Water tank lifted', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'problem', + 'friendly_name': 'Test Fan Water tank lifted', + }), + 'entity_id': 'binary_sensor.test_fan_water_tank_lifted', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), dict({ 'device_class': None, 'disabled': False, 'disabled_by': None, 'domain': 'fan', 'entity_category': None, - 'entity_id': 'fan.fan', + 'entity_id': 'fan.test_fan', 'icon': None, 'name': None, 'original_device_class': None, @@ -225,14 +247,12 @@ 'original_name': None, 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan', + 'friendly_name': 'Test Fan', 'preset_modes': list([ - 'auto', - 'sleep', ]), 'supported_features': 57, }), - 'entity_id': 'fan.fan', + 'entity_id': 'fan.test_fan', 'last_changed': str, 'last_reported': str, 'last_updated': str, @@ -246,7 +266,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.fan_air_quality', + 'entity_id': 'sensor.test_fan_air_quality', 'icon': None, 'name': None, 'original_device_class': None, @@ -254,9 +274,9 @@ 'original_name': 'Air quality', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan Air quality', + 'friendly_name': 'Test Fan Air quality', }), - 'entity_id': 'sensor.fan_air_quality', + 'entity_id': 'sensor.test_fan_air_quality', 'last_changed': str, 'last_reported': str, 'last_updated': str, @@ -270,7 +290,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': 'diagnostic', - 'entity_id': 'sensor.fan_filter_lifetime', + 'entity_id': 'sensor.test_fan_filter_lifetime', 'icon': None, 'name': None, 'original_device_class': None, @@ -278,11 +298,11 @@ 'original_name': 'Filter lifetime', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan Filter lifetime', + 'friendly_name': 'Test Fan Filter lifetime', 'state_class': 'measurement', 'unit_of_measurement': '%', }), - 'entity_id': 'sensor.fan_filter_lifetime', + 'entity_id': 'sensor.test_fan_filter_lifetime', 'last_changed': str, 'last_reported': str, 'last_updated': str, @@ -290,13 +310,40 @@ }), 'unit_of_measurement': '%', }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_fan_pm2_5', + 'icon': None, + 'name': None, + 'original_device_class': 'pm25', + 'original_icon': None, + 'original_name': 'PM2.5', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'pm25', + 'friendly_name': 'Test Fan PM2.5', + 'state_class': 'measurement', + 'unit_of_measurement': 'μg/m³', + }), + 'entity_id': 'sensor.test_fan_pm2_5', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': 'μg/m³', + }), dict({ 'device_class': None, 'disabled': False, 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.fan_display', + 'entity_id': 'switch.test_fan_display', 'icon': None, 'name': None, 'original_device_class': None, @@ -304,9 +351,9 @@ 'original_name': 'Display', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan Display', + 'friendly_name': 'Test Fan Display', }), - 'entity_id': 'switch.fan_display', + 'entity_id': 'switch.test_fan_display', 'last_changed': str, 'last_reported': str, 'last_updated': str, @@ -315,22 +362,65 @@ 'unit_of_measurement': None, }), ]), - 'name': 'Fan', + 'name': 'Test Fan', 'name_by_user': None, }), - 'mac_id': '**REDACTED**', - 'manager': '**REDACTED**', - 'mode': None, - 'modes': list([ - 'manual', - 'auto', - 'sleep', - 'off', + 'is_on': 'Method', + 'last_response': 'Method', + 'latest_firm_version': 'Method', + 'mac_id': 'Method', + 'manager': 'Method', + 'manual_mode': 'Method', + 'method_calls': list([ ]), - 'pid': None, - 'speed': None, - 'sub_device_no': None, - 'type': 'wifi-air', - 'uuid': '**REDACTED**', + 'mock_add_spec': 'Method', + 'mock_calls': list([ + ]), + 'mode_toggle': 'Method', + 'modes': list([ + ]), + 'normal_mode': 'Method', + 'pid': 'Method', + 'product_type': 'Method', + 'request_keys': 'Method', + 'reset_mock': 'Method', + 'return_value': 'Method', + 'set_advanced_sleep_mode': 'Method', + 'set_auto_mode': 'Method', + 'set_fan_speed': 'Method', + 'set_manual_mode': 'Method', + 'set_mode': 'Method', + 'set_normal_mode': 'Method', + 'set_sleep_mode': 'Method', + 'set_state': 'Method', + 'set_timer': 'Method', + 'set_turbo_mode': 'Method', + 'side_effect': None, + 'sleep_mode': 'Method', + 'sleep_preferences': 'Method', + 'state': 'Method', + 'sub_device_no': 'Method', + 'supports_displaying_type': 'Method', + 'supports_mute': 'Method', + 'supports_oscillation': 'Method', + 'to_dict': 'Method', + 'to_json': 'Method', + 'to_jsonb': 'Method', + 'toggle_display': 'Method', + 'toggle_displaying_type': 'Method', + 'toggle_mute': 'Method', + 'toggle_oscillation': 'Method', + 'toggle_switch': 'Method', + 'turn_off': 'Method', + 'turn_off_display': 'Method', + 'turn_off_mute': 'Method', + 'turn_off_oscillation': 'Method', + 'turn_on': 'Method', + 'turn_on_display': 'Method', + 'turn_on_mute': 'Method', + 'turn_on_oscillation': 'Method', + 'type': 'Method', + 'update': 'Method', + 'uuid': 'Method', }) # --- diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 86cfa8198ba..88b6bc64ebb 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -78,8 +78,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'active_time': 0, + 'child_lock': False, + 'display_status': 'on', 'friendly_name': 'Air Purifier 131s', 'mode': 'sleep', + 'night_light': None, 'percentage': None, 'percentage_step': 33.333333333333336, 'preset_mode': 'sleep', @@ -87,7 +90,6 @@ 'auto', 'sleep', ]), - 'screen_status': 'on', 'supported_features': , }), 'context': , @@ -175,17 +177,18 @@ # name: test_fan_state[Air Purifier 200s][fan.air_purifier_200s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': None, 'child_lock': False, + 'display_status': 'on', 'friendly_name': 'Air Purifier 200s', 'mode': 'manual', - 'night_light': 'off', + 'night_light': , 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, 'preset_modes': list([ 'sleep', ]), - 'screen_status': True, 'supported_features': , }), 'context': , @@ -274,10 +277,12 @@ # name: test_fan_state[Air Purifier 400s][fan.air_purifier_400s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': None, 'child_lock': False, + 'display_status': 'on', 'friendly_name': 'Air Purifier 400s', 'mode': 'manual', - 'night_light': 'off', + 'night_light': , 'percentage': 25, 'percentage_step': 25.0, 'preset_mode': None, @@ -285,7 +290,6 @@ 'auto', 'sleep', ]), - 'screen_status': True, 'supported_features': , }), 'context': , @@ -374,10 +378,12 @@ # name: test_fan_state[Air Purifier 600s][fan.air_purifier_600s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': None, 'child_lock': False, + 'display_status': 'on', 'friendly_name': 'Air Purifier 600s', 'mode': 'manual', - 'night_light': 'off', + 'night_light': , 'percentage': 25, 'percentage_step': 25.0, 'preset_mode': None, @@ -385,7 +391,6 @@ 'auto', 'sleep', ]), - 'screen_status': True, 'supported_features': , }), 'context': , @@ -661,12 +666,12 @@ # name: test_fan_state[SmartTowerFan][fan.smarttowerfan] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'child_lock': False, + 'active_time': None, + 'display_status': 'off', 'friendly_name': 'SmartTowerFan', 'mode': 'normal', - 'night_light': 'off', 'percentage': None, - 'percentage_step': 7.6923076923076925, + 'percentage_step': 8.333333333333334, 'preset_mode': 'normal', 'preset_modes': list([ 'advancedSleep', @@ -674,7 +679,6 @@ 'normal', 'turbo', ]), - 'screen_status': False, 'supported_features': , }), 'context': , diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index df2dad8825d..a55659b6130 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -560,29 +560,39 @@ # name: test_light_state[Temperature Light][light.temperature_light] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, + 'brightness': 128, + 'color_mode': , + 'color_temp': 262, + 'color_temp_kelvin': 3816, 'friendly_name': 'Temperature Light', - 'hs_color': None, + 'hs_color': tuple( + 26.914, + 38.308, + ), 'max_color_temp_kelvin': 6500, 'max_mireds': 370, 'min_color_temp_kelvin': 2700, 'min_mireds': 153, - 'rgb_color': None, + 'rgb_color': tuple( + 255, + 201, + 157, + ), 'supported_color_modes': list([ , ]), 'supported_features': , - 'xy_color': None, + 'xy_color': tuple( + 0.432, + 0.368, + ), }), 'context': , 'entity_id': 'light.temperature_light', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_light_state[Wall Switch][devices] diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index e29255cdc72..23d31d33bcb 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -34,6 +34,39 @@ # --- # name: test_sensor_state[Air Purifier 131s][entities] list([ + 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': None, + 'entity_id': 'sensor.air_purifier_131s_air_quality', + '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': 'Air quality', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'air-purifier-air-quality', + 'unit_of_measurement': None, + }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -69,39 +102,6 @@ 'unique_id': 'air-purifier-filter-life', 'unit_of_measurement': '%', }), - 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': None, - 'entity_id': 'sensor.air_purifier_131s_air_quality', - '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': 'Air quality', - 'platform': 'vesync', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': 'air-purifier-air-quality', - 'unit_of_measurement': None, - }), ]) # --- # name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_air_quality] @@ -167,6 +167,39 @@ # --- # name: test_sensor_state[Air Purifier 200s][entities] list([ + 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': None, + 'entity_id': 'sensor.air_purifier_200s_air_quality', + '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': 'Air quality', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-air-quality', + 'unit_of_measurement': None, + }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -204,6 +237,19 @@ }), ]) # --- +# name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_air_quality] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 200s Air quality', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_200s_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'None', + }) +# --- # name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_filter_lifetime] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -254,41 +300,6 @@ # --- # name: test_sensor_state[Air Purifier 400s][entities] list([ - 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': , - 'entity_id': 'sensor.air_purifier_400s_filter_lifetime', - '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': 'Filter lifetime', - 'platform': 'vesync', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'filter_life', - 'unique_id': '400s-purifier-filter-life', - 'unit_of_measurement': '%', - }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -357,6 +368,41 @@ 'unique_id': '400s-purifier-pm25', 'unit_of_measurement': 'μg/m³', }), + 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': , + 'entity_id': 'sensor.air_purifier_400s_filter_lifetime', + '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': 'Filter lifetime', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_life', + 'unique_id': '400s-purifier-filter-life', + 'unit_of_measurement': '%', + }), ]) # --- # name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_air_quality] @@ -369,7 +415,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': 'excellent', }) # --- # name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_filter_lifetime] @@ -400,7 +446,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '5', }) # --- # name: test_sensor_state[Air Purifier 600s][devices] @@ -438,41 +484,6 @@ # --- # name: test_sensor_state[Air Purifier 600s][entities] list([ - 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': , - 'entity_id': 'sensor.air_purifier_600s_filter_lifetime', - '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': 'Filter lifetime', - 'platform': 'vesync', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'filter_life', - 'unique_id': '600s-purifier-filter-life', - 'unit_of_measurement': '%', - }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -541,6 +552,41 @@ 'unique_id': '600s-purifier-pm25', 'unit_of_measurement': 'μg/m³', }), + 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': , + 'entity_id': 'sensor.air_purifier_600s_filter_lifetime', + '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': 'Filter lifetime', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_life', + 'unique_id': '600s-purifier-filter-life', + 'unit_of_measurement': '%', + }), ]) # --- # name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_air_quality] @@ -553,7 +599,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': 'excellent', }) # --- # name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_filter_lifetime] @@ -584,7 +630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '5', }) # --- # name: test_sensor_state[Dimmable Light][devices] @@ -872,44 +918,6 @@ # --- # name: test_sensor_state[Outlet][entities] list([ - 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.outlet_current_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current power', - 'platform': 'vesync', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'current_power', - 'unique_id': 'outlet-power', - 'unit_of_measurement': , - }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1100,6 +1108,44 @@ 'unique_id': 'outlet-voltage', 'unit_of_measurement': , }), + 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.outlet_current_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current power', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_power', + 'unique_id': 'outlet-power', + 'unit_of_measurement': , + }), ]) # --- # name: test_sensor_state[Outlet][sensor.outlet_current_power] @@ -1147,7 +1193,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensor_state[Outlet][sensor.outlet_energy_use_today] @@ -1163,7 +1209,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensor_state[Outlet][sensor.outlet_energy_use_weekly] @@ -1179,7 +1225,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensor_state[Outlet][sensor.outlet_energy_use_yearly] @@ -1195,7 +1241,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensor_state[SmartTowerFan][devices] diff --git a/tests/components/vesync/test_binary_sensor.py b/tests/components/vesync/test_binary_sensor.py new file mode 100644 index 00000000000..5863270f7f5 --- /dev/null +++ b/tests/components/vesync/test_binary_sensor.py @@ -0,0 +1,51 @@ +"""Tests for the binary sensor module.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .common import ALL_DEVICE_NAMES, mock_devices_response + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) +async def test_sensor_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + device_name: str, +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + # Configure the API devices call for device_name + mock_devices_response(aioclient_mock, device_name) + + # setup platform - only including the named device + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check device registry + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert devices == snapshot(name="devices") + + # Check entity registry + entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entity.domain == BINARY_SENSOR_DOMAIN + ] + assert entities == snapshot(name="entities") + + # Check states + for entity in entities: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) diff --git a/tests/components/vesync/test_config_flow.py b/tests/components/vesync/test_config_flow.py index 38f28e73aed..4eb41d8f24c 100644 --- a/tests/components/vesync/test_config_flow.py +++ b/tests/components/vesync/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +from pyvesync.utils.errors import VeSyncLoginError + from homeassistant.components.vesync import DOMAIN, config_flow from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -28,7 +30,10 @@ async def test_invalid_login_error(hass: HomeAssistant) -> None: test_dict = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} flow = config_flow.VeSyncFlowHandler() flow.hass = hass - with patch("pyvesync.vesync.VeSync.login", return_value=False): + with patch( + "pyvesync.vesync.VeSync.login", + side_effect=VeSyncLoginError("Mock login failed"), + ): result = await flow.async_step_user(user_input=test_dict) assert result["type"] is FlowResultType.FORM @@ -41,7 +46,7 @@ async def test_config_flow_user_input(hass: HomeAssistant) -> None: flow.hass = hass result = await flow.async_step_user() assert result["type"] is FlowResultType.FORM - with patch("pyvesync.vesync.VeSync.login", return_value=True): + with patch("pyvesync.vesync.VeSync.login"): result = await flow.async_step_user( {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} ) @@ -62,7 +67,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - with patch("pyvesync.vesync.VeSync.login", return_value=True): + with patch("pyvesync.vesync.VeSync.login"): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, @@ -89,14 +94,17 @@ async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - with patch("pyvesync.vesync.VeSync.login", return_value=False): + with patch( + "pyvesync.vesync.VeSync.login", + side_effect=VeSyncLoginError("Mock login failed"), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, ) assert result["type"] is FlowResultType.FORM - with patch("pyvesync.vesync.VeSync.login", return_value=True): + with patch("pyvesync.vesync.VeSync.login"): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index c2b789a932e..31e0e514dd3 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -1,8 +1,5 @@ """Tests for the diagnostics data provided by the VeSync integration.""" -from unittest.mock import patch - -from pyvesync.helpers import Helpers from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type @@ -13,12 +10,6 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from .common import ( - call_api_side_effect__no_devices, - call_api_side_effect__single_fan, - call_api_side_effect__single_humidifier, -) - from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -32,12 +23,11 @@ async def test_async_get_config_entry_diagnostics__no_devices( config_entry: ConfigEntry, config: ConfigType, snapshot: SnapshotAssertion, + manager, ) -> None: """Test diagnostics for config entry.""" - with patch.object(Helpers, "call_api") as call_api: - call_api.side_effect = call_api_side_effect__no_devices - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) @@ -51,12 +41,14 @@ async def test_async_get_config_entry_diagnostics__single_humidifier( config_entry: ConfigEntry, config: ConfigType, snapshot: SnapshotAssertion, + manager, + humidifier, ) -> None: """Test diagnostics for config entry.""" - with patch.object(Helpers, "call_api") as call_api: - call_api.side_effect = call_api_side_effect__single_humidifier - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + manager._dev_list["humidifiers"].append(humidifier) + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) @@ -71,15 +63,17 @@ async def test_async_get_device_diagnostics__single_fan( config_entry: ConfigEntry, config: ConfigType, snapshot: SnapshotAssertion, + manager, + fan, ) -> None: """Test diagnostics for config entry.""" - with patch.object(Helpers, "call_api") as call_api: - call_api.side_effect = call_api_side_effect__single_fan - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + manager._dev_list["fans"].append(fan) + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, "abcdefghabcdefghabcdefghabcdefgh")}, + identifiers={(DOMAIN, "fan")}, ) assert device is not None @@ -104,6 +98,15 @@ async def test_async_get_device_diagnostics__single_fan( "home_assistant.entities.3.state.last_changed": (str,), "home_assistant.entities.3.state.last_reported": (str,), "home_assistant.entities.3.state.last_updated": (str,), + "home_assistant.entities.4.state.last_changed": (str,), + "home_assistant.entities.4.state.last_reported": (str,), + "home_assistant.entities.4.state.last_updated": (str,), + "home_assistant.entities.5.state.last_changed": (str,), + "home_assistant.entities.5.state.last_reported": (str,), + "home_assistant.entities.5.state.last_updated": (str,), + "home_assistant.entities.6.state.last_changed": (str,), + "home_assistant.entities.6.state.last_reported": (str,), + "home_assistant.entities.6.state.last_updated": (str,), } ) ) diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index cf572e5b981..e5c59bef30f 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -1,10 +1,9 @@ """Tests for the fan module.""" from contextlib import nullcontext -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN @@ -16,6 +15,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import ALL_DEVICE_NAMES, ENTITY_FAN, mock_devices_response from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker NoException = nullcontext() @@ -27,13 +27,13 @@ async def test_fan_state( config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name - mock_devices_response(requests_mock, device_name) + mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) @@ -61,20 +61,23 @@ async def test_fan_state( @pytest.mark.parametrize( ("action", "command"), [ - (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncTowerFan.turn_on"), - (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + (SERVICE_TURN_ON, "pyvesync.devices.vesyncfan.VeSyncTowerFan.turn_on"), + (SERVICE_TURN_OFF, "pyvesync.devices.vesyncfan.VeSyncTowerFan.turn_off"), ], ) async def test_turn_on_off_success( hass: HomeAssistant, fan_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, action: str, command: str, ) -> None: """Test turn_on and turn_off method.""" + mock_devices_response(aioclient_mock, "SmartTowerFan") + with ( - patch(command, return_value=True) as method_mock, + patch(command, new_callable=AsyncMock, return_value=True) as method_mock, ): with patch( "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" @@ -94,8 +97,14 @@ async def test_turn_on_off_success( @pytest.mark.parametrize( ("action", "command"), [ - (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncTowerFan.turn_on"), - (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + ( + SERVICE_TURN_ON, + "pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice.turn_on", + ), + ( + SERVICE_TURN_OFF, + "pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice.turn_off", + ), ], ) async def test_turn_on_off_raises_error( @@ -141,7 +150,7 @@ async def test_set_preset_mode( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncTowerFan.normal_mode", + "pyvesync.devices.vesyncfan.VeSyncTowerFan.normal_mode", return_value=api_response, ) as method_mock, ): diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index d5057c44951..e96efd355ee 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -73,7 +73,9 @@ async def test_set_target_humidity_invalid( # Setting value out of range results in ServiceValidationError and # VeSyncHumid200300S.set_humidity does not get called. with ( - patch("pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity") as method_mock, + patch( + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_humidity" + ) as method_mock, pytest.raises(ServiceValidationError), ): await hass.services.async_call( @@ -102,7 +104,7 @@ async def test_set_target_humidity( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity", + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_humidity", return_value=api_response, ) as method_mock, ): @@ -133,7 +135,8 @@ async def test_turn_on( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on", return_value=api_response + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.turn_on", + return_value=api_response, ) as method_mock, ): with patch( @@ -168,7 +171,8 @@ async def test_turn_off( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off", return_value=api_response + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.turn_off", + return_value=api_response, ) as method_mock, ): with patch( @@ -193,7 +197,7 @@ async def test_set_mode_invalid( """Test handling of invalid value in set_mode method.""" with patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode" + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_humidity_mode" ) as method_mock: with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -222,7 +226,7 @@ async def test_set_mode( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode", + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_humidity_mode", return_value=api_response, ) as method_mock, ): @@ -257,17 +261,14 @@ async def test_invalid_mist_modes( """Test unsupported mist mode.""" humidifier.mist_modes = ["invalid_mode"] + manager._dev_list["humidifiers"].append(humidifier) - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[humidifier], - ): - caplog.clear() - caplog.set_level(logging.WARNING) + caplog.clear() + caplog.set_level(logging.WARNING) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert "Unknown mode 'invalid_mode'" in caplog.text + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'invalid_mode'" in caplog.text async def test_valid_mist_modes( @@ -280,18 +281,15 @@ async def test_valid_mist_modes( """Test supported mist mode.""" humidifier.mist_modes = ["auto", "manual"] + manager._dev_list["humidifiers"].append(humidifier) - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[humidifier], - ): - caplog.clear() - caplog.set_level(logging.WARNING) + caplog.clear() + caplog.set_level(logging.WARNING) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert "Unknown mode 'auto'" not in caplog.text - assert "Unknown mode 'manual'" not in caplog.text + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'auto'" not in caplog.text + assert "Unknown mode 'manual'" not in caplog.text async def test_set_mode_sleep_turns_display_off( @@ -308,17 +306,14 @@ async def test_set_mode_sleep_turns_display_off( VS_HUMIDIFIER_MODE_MANUAL, VS_HUMIDIFIER_MODE_SLEEP, ] + manager._dev_list["humidifiers"].append(humidifier) - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[humidifier], - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() with ( patch.object(humidifier, "set_humidity_mode", return_value=True), - patch.object(humidifier, "set_display") as display_mock, + patch.object(humidifier, "toggle_display") as display_mock, ): await hass.services.async_call( HUMIDIFIER_DOMAIN, diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index d1e76174ea0..de6aa358e76 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -1,11 +1,12 @@ """Tests for the init module.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch from pyvesync import VeSync +from pyvesync.utils.errors import VeSyncLoginError from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry -from homeassistant.components.vesync.const import DOMAIN, VS_DEVICES, VS_MANAGER +from homeassistant.components.vesync.const import DOMAIN, VS_MANAGER from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,7 +21,7 @@ async def test_async_setup_entry__not_login( manager: VeSync, ) -> None: """Test setup does not create config entry when not logged in.""" - manager.login = Mock(return_value=False) + manager.login = AsyncMock(side_effect=VeSyncLoginError("Mock login failed")) assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -54,18 +55,14 @@ async def test_async_setup_entry__no_devices( assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_DEVICES] + assert not hass.data[DOMAIN][VS_MANAGER].devices async def test_async_setup_entry__loads_fans( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan ) -> None: """Test setup connects to vesync and loads fan.""" - fans = [fan] - manager.fans = fans - manager._dev_list = { - "fans": fans, - } + manager._dev_list["fans"].append(fan) with patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock: assert await async_setup_entry(hass, config_entry) @@ -85,7 +82,7 @@ async def test_async_setup_entry__loads_fans( ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan] + assert list(hass.data[DOMAIN][VS_MANAGER].devices) == [fan] async def test_async_new_device_discovery( @@ -97,30 +94,23 @@ async def test_async_new_device_discovery( # Assert platforms loaded await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - assert not hass.data[DOMAIN][VS_DEVICES] + assert not hass.data[DOMAIN][VS_MANAGER].devices # Mock discovery of new fan which would get added to VS_DEVICES. - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[fan], - ): - await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + manager._dev_list["fans"].append(fan) + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) - assert manager.login.call_count == 1 - assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan] + assert manager.get_devices.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert list(hass.data[DOMAIN][VS_MANAGER].devices) == [fan] # Mock discovery of new humidifier which would invoke discovery in all platforms. - # The mocked humidifier needs to have all properties populated for correct processing. - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[humidifier], - ): - await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + manager._dev_list["humidifiers"].append(humidifier) + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) - assert manager.login.call_count == 1 - assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan, humidifier] + assert manager.get_devices.call_count == 2 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert list(hass.data[DOMAIN][VS_MANAGER].devices) == [fan, humidifier] async def test_migrate_config_entry( diff --git a/tests/components/vesync/test_light.py b/tests/components/vesync/test_light.py index 7300e28e406..ce67efe3ed2 100644 --- a/tests/components/vesync/test_light.py +++ b/tests/components/vesync/test_light.py @@ -1,7 +1,6 @@ """Tests for the light module.""" import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN @@ -11,6 +10,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import ALL_DEVICE_NAMES, mock_devices_response from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) @@ -20,13 +20,13 @@ async def test_light_state( config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name - mock_devices_response(requests_mock, device_name) + mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/vesync/test_number.py b/tests/components/vesync/test_number.py index a9230b76db0..debd95cad2b 100644 --- a/tests/components/vesync/test_number.py +++ b/tests/components/vesync/test_number.py @@ -25,7 +25,7 @@ async def test_set_mist_level_bad_range( with ( pytest.raises(ServiceValidationError), patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_mist_level", return_value=True, ) as method_mock, ): @@ -45,7 +45,7 @@ async def test_set_mist_level( """Test set_mist_level usage.""" with patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_mist_level", return_value=True, ) as method_mock: await hass.services.async_call( diff --git a/tests/components/vesync/test_platform.py b/tests/components/vesync/test_platform.py deleted file mode 100644 index fa1e24f4628..00000000000 --- a/tests/components/vesync/test_platform.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Tests for the coordinator.""" - -from datetime import timedelta - -from freezegun.api import FrozenDateTimeFactory -import requests_mock - -from homeassistant.components.vesync.const import DOMAIN, UPDATE_INTERVAL -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant - -from .common import ( - mock_air_purifier_400s_update_response, - mock_device_response, - mock_multiple_device_responses, - mock_outlet_energy_response, -) - -from tests.common import MockConfigEntry, async_fire_time_changed - - -async def test_entity_update( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - requests_mock: requests_mock.Mocker, -) -> None: - """Test Vesync coordinator data update. - - This test sets up a single device `Air Purifier 400s` and then updates it via the coordinator. - """ - - config_data = {CONF_PASSWORD: "username", CONF_USERNAME: "password"} - config_entry = MockConfigEntry( - data=config_data, - domain=DOMAIN, - unique_id="vesync_unique_id_1", - entry_id="1", - ) - - mock_multiple_device_responses(requests_mock, ["Air Purifier 400s", "Outlet"]) - - expected_entities = [ - # From "Air Purifier 400s" - "fan.air_purifier_400s", - "sensor.air_purifier_400s_filter_lifetime", - "sensor.air_purifier_400s_air_quality", - "sensor.air_purifier_400s_pm2_5", - # From Outlet - "switch.outlet", - "sensor.outlet_current_power", - "sensor.outlet_energy_use_today", - "sensor.outlet_energy_use_weekly", - "sensor.outlet_energy_use_monthly", - "sensor.outlet_energy_use_yearly", - "sensor.outlet_current_voltage", - ] - - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - for entity_id in expected_entities: - assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "5" - assert hass.states.get("sensor.outlet_current_voltage").state == "120.0" - assert hass.states.get("sensor.outlet_energy_use_weekly").state == "0" - - # Update the mock responses - mock_air_purifier_400s_update_response(requests_mock) - mock_outlet_energy_response(requests_mock, "Outlet", {"totalEnergy": 2.2}) - mock_device_response(requests_mock, "Outlet", {"voltage": 129}) - - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) - async_fire_time_changed(hass) - await hass.async_block_till_done(True) - - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "15" - assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" - - # Test energy update - # pyvesync only updates energy parameters once every 6 hours. - freezer.tick(timedelta(hours=6)) - async_fire_time_changed(hass) - await hass.async_block_till_done(True) - - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "15" - assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" - assert hass.states.get("sensor.outlet_energy_use_weekly").state == "2.2" diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py index c96d687dfd2..a4183d0c0cb 100644 --- a/tests/components/vesync/test_select.py +++ b/tests/components/vesync/test_select.py @@ -36,7 +36,7 @@ async def test_humidifier_set_nightlight_level( ) # Assert that setter API was invoked with the expected translated value - humidifier_300s.set_night_light_brightness.assert_called_once_with( + humidifier_300s.set_nightlight_brightness.assert_called_once_with( HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP[HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM] ) # Assert that devices were refreshed diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index d4e6abcdbab..792c21f98a9 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -1,7 +1,6 @@ """Tests for the sensor module.""" import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -11,6 +10,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import ALL_DEVICE_NAMES, ENTITY_HUMIDIFIER_HUMIDITY, mock_devices_response from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) @@ -20,13 +20,13 @@ async def test_sensor_state( config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name - mock_devices_response(requests_mock, device_name) + mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/vesync/test_switch.py b/tests/components/vesync/test_switch.py index b0af5afc5d2..d99d4b46136 100644 --- a/tests/components/vesync/test_switch.py +++ b/tests/components/vesync/test_switch.py @@ -4,7 +4,6 @@ from contextlib import nullcontext from unittest.mock import patch import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -16,6 +15,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import ALL_DEVICE_NAMES, ENTITY_SWITCH_DISPLAY, mock_devices_response from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker NoException = nullcontext() @@ -27,13 +27,13 @@ async def test_switch_state( config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name - mock_devices_response(requests_mock, device_name) + mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) @@ -61,18 +61,27 @@ async def test_switch_state( @pytest.mark.parametrize( ("action", "command"), [ - (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), - (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ( + SERVICE_TURN_ON, + "pyvesync.devices.vesynchumidifier.VeSyncHumid200S.toggle_display", + ), + ( + SERVICE_TURN_OFF, + "pyvesync.devices.vesynchumidifier.VeSyncHumid200S.toggle_display", + ), ], ) async def test_turn_on_off_display_success( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, action: str, command: str, ) -> None: """Test switch turn on and off command with success response.""" + mock_devices_response(aioclient_mock, "Humidifier 200s") + with ( patch( command, @@ -97,18 +106,27 @@ async def test_turn_on_off_display_success( @pytest.mark.parametrize( ("action", "command"), [ - (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), - (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ( + SERVICE_TURN_ON, + "pyvesync.devices.vesynchumidifier.VeSyncHumid200S.toggle_display", + ), + ( + SERVICE_TURN_OFF, + "pyvesync.devices.vesynchumidifier.VeSyncHumid200S.toggle_display", + ), ], ) async def test_turn_on_off_display_raises_error( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, action: str, command: str, ) -> None: """Test switch turn on and off command raises HomeAssistantError.""" + mock_devices_response(aioclient_mock, "Humidifier 200s") + with ( patch( command, diff --git a/tests/components/vicare/fixtures/VitoChargeVX3.json b/tests/components/vicare/fixtures/VitoChargeVX3.json new file mode 100644 index 00000000000..fe2f94f3e06 --- /dev/null +++ b/tests/components/vicare/fixtures/VitoChargeVX3.json @@ -0,0 +1,42 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "################", + "feature": "ess.transfer.charge.cumulated", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "wattHour", + "value": 5449 + }, + "currentMonth": { + "type": "number", + "unit": "wattHour", + "value": 143145 + }, + "currentWeek": { + "type": "number", + "unit": "wattHour", + "value": 5450 + }, + "currentYear": { + "type": "number", + "unit": "wattHour", + "value": 1251105 + }, + "lifeCycle": { + "type": "number", + "unit": "wattHour", + "value": 1879163 + } + }, + "timestamp": "2025-09-29T16:45:15.994Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/################/features/ess.transfer.charge.cumulated" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_number.ambr b/tests/components/vicare/snapshots/test_number.ambr index 729d1403ad8..8a271d5d0f4 100644 --- a/tests/components/vicare/snapshots/test_number.ambr +++ b/tests/components/vicare/snapshots/test_number.ambr @@ -7,7 +7,7 @@ 'capabilities': dict({ 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -46,7 +46,7 @@ 'friendly_name': 'model0 Comfort temperature', 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, 'unit_of_measurement': , }), @@ -66,7 +66,7 @@ 'capabilities': dict({ 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -105,7 +105,7 @@ 'friendly_name': 'model0 Comfort temperature', 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, 'unit_of_measurement': , }), @@ -125,7 +125,7 @@ 'capabilities': dict({ 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1, }), 'config_entry_id': , @@ -164,7 +164,7 @@ 'friendly_name': 'model0 DHW temperature', 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1, 'unit_of_measurement': , }), @@ -184,7 +184,7 @@ 'capabilities': dict({ 'max': 40, 'min': -13, - 'mode': , + 'mode': , 'step': 1, }), 'config_entry_id': , @@ -223,7 +223,7 @@ 'friendly_name': 'model0 Heating curve shift', 'max': 40, 'min': -13, - 'mode': , + 'mode': , 'step': 1, 'unit_of_measurement': , }), @@ -243,7 +243,7 @@ 'capabilities': dict({ 'max': 40, 'min': -13, - 'mode': , + 'mode': , 'step': 1, }), 'config_entry_id': , @@ -282,7 +282,7 @@ 'friendly_name': 'model0 Heating curve shift', 'max': 40, 'min': -13, - 'mode': , + 'mode': , 'step': 1, 'unit_of_measurement': , }), @@ -302,7 +302,7 @@ 'capabilities': dict({ 'max': 3.5, 'min': 0.2, - 'mode': , + 'mode': , 'step': 0.1, }), 'config_entry_id': , @@ -340,7 +340,7 @@ 'friendly_name': 'model0 Heating curve slope', 'max': 3.5, 'min': 0.2, - 'mode': , + 'mode': , 'step': 0.1, }), 'context': , @@ -359,7 +359,7 @@ 'capabilities': dict({ 'max': 3.5, 'min': 0.2, - 'mode': , + 'mode': , 'step': 0.1, }), 'config_entry_id': , @@ -397,7 +397,7 @@ 'friendly_name': 'model0 Heating curve slope', 'max': 3.5, 'min': 0.2, - 'mode': , + 'mode': , 'step': 0.1, }), 'context': , @@ -416,7 +416,7 @@ 'capabilities': dict({ 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -455,7 +455,7 @@ 'friendly_name': 'model0 Normal temperature', 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, 'unit_of_measurement': , }), @@ -475,7 +475,7 @@ 'capabilities': dict({ 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -514,7 +514,7 @@ 'friendly_name': 'model0 Normal temperature', 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, 'unit_of_measurement': , }), @@ -534,7 +534,7 @@ 'capabilities': dict({ 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -573,7 +573,7 @@ 'friendly_name': 'model0 Reduced temperature', 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, 'unit_of_measurement': , }), @@ -593,7 +593,7 @@ 'capabilities': dict({ 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -632,7 +632,7 @@ 'friendly_name': 'model0 Reduced temperature', 'max': 100.0, 'min': 0.0, - 'mode': , + 'mode': , 'step': 1.0, 'unit_of_measurement': , }), diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 85da1f1d948..22cba704dcf 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1122,6 +1122,118 @@ 'state': '25.5', }) # --- +# name: test_all_entities[type:ess-vicare/VitoChargeVX3.json][sensor.model0_battery_charge_total-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.model0_battery_charge_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery charge total', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ess_charge_total', + 'unique_id': 'gateway0_deviceId0-ess_charge_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:ess-vicare/VitoChargeVX3.json][sensor.model0_battery_charge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Battery charge total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_battery_charge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1879163', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_boiler_supply_temperature-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.model0_boiler_supply_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boiler supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'boiler_supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-boiler_supply_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_boiler_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Boiler supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_boiler_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.6', + }) +# --- # name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_buffer_main_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2505,6 +2617,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2515,7 +2630,7 @@ 'supported_features': 0, 'translation_key': 'supply_pressure', 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_pressure', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_pressure-state] @@ -2524,6 +2639,7 @@ 'device_class': 'pressure', 'friendly_name': 'model0 Supply pressure', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.model0_supply_pressure', diff --git a/tests/components/vicare/test_sensor.py b/tests/components/vicare/test_sensor.py index daad6bfa1c8..be7418291a8 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -22,6 +22,7 @@ from tests.common import MockConfigEntry, snapshot_platform ("type:boiler", "vicare/Vitodens300W.json"), ("type:heatpump", "vicare/Vitocal250A.json"), ("type:ventilation", "vicare/ViAir300F.json"), + ("type:ess", "vicare/VitoChargeVX3.json"), ], ) async def test_all_entities( diff --git a/tests/components/victron_remote_monitoring/__init__.py b/tests/components/victron_remote_monitoring/__init__.py new file mode 100644 index 00000000000..2d46ed56b2c --- /dev/null +++ b/tests/components/victron_remote_monitoring/__init__.py @@ -0,0 +1 @@ +"""Tests for the Victron Remote Monitoring integration.""" diff --git a/tests/components/victron_remote_monitoring/conftest.py b/tests/components/victron_remote_monitoring/conftest.py new file mode 100644 index 00000000000..7202f216676 --- /dev/null +++ b/tests/components/victron_remote_monitoring/conftest.py @@ -0,0 +1,125 @@ +"""Common fixtures for the Victron VRM Forecasts tests.""" + +from collections.abc import Generator +import datetime +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from victron_vrm.models.aggregations import ForecastAggregations + +from homeassistant.components.victron_remote_monitoring.const import ( + CONF_API_TOKEN, + CONF_SITE_ID, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +CONST_1_HOUR = 3600000 +CONST_12_HOURS = 43200000 +CONST_24_HOURS = 86400000 +CONST_FORECAST_START = 1745359200000 +CONST_FORECAST_END = CONST_FORECAST_START + (CONST_24_HOURS * 2) + (CONST_1_HOUR * 13) +# Do not change the values in this fixture; tests depend on them +CONST_FORECAST_RECORDS = [ + # Yesterday + [CONST_FORECAST_START + CONST_12_HOURS, 5050.1], + [CONST_FORECAST_START + (CONST_12_HOURS + CONST_1_HOUR), 5000.2], + # Today + [CONST_FORECAST_START + (CONST_24_HOURS + CONST_12_HOURS), 2250.3], + [CONST_FORECAST_START + CONST_24_HOURS + (CONST_1_HOUR * 13), 2000.4], + # Tomorrow + [CONST_FORECAST_START + (CONST_24_HOURS * 2) + CONST_12_HOURS, 1000.5], + [CONST_FORECAST_START + (CONST_24_HOURS * 2) + (CONST_1_HOUR * 13), 500.6], +] + + +@pytest.fixture +def mock_setup_entry(mock_vrm_client) -> Generator[AsyncMock]: + """Override async_setup_entry while client is patched.""" + with patch( + "homeassistant.components.victron_remote_monitoring.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Override async_config_entry.""" + return MockConfigEntry( + title="Test VRM Forecasts", + unique_id="123456", + version=1, + domain=DOMAIN, + data={ + CONF_API_TOKEN: "test_api_key", + CONF_SITE_ID: 123456, + }, + options={}, + ) + + +@pytest.fixture(autouse=True) +def mock_vrm_client() -> Generator[AsyncMock]: + """Patch the VictronVRMClient to supply forecast and site data.""" + + def fake_dt_now(): + return datetime.datetime.fromtimestamp( + (CONST_FORECAST_START + (CONST_24_HOURS + CONST_12_HOURS) + 60000) / 1000, + tz=datetime.UTC, + ) + + solar_agg = ForecastAggregations( + start=CONST_FORECAST_START // 1000, + end=CONST_FORECAST_END // 1000, + records=[(x // 1000, y) for x, y in CONST_FORECAST_RECORDS], + custom_dt_now=fake_dt_now, + site_id=123456, + ) + consumption_agg = ForecastAggregations( + start=CONST_FORECAST_START // 1000, + end=CONST_FORECAST_END // 1000, + records=[(x // 1000, y) for x, y in CONST_FORECAST_RECORDS], + custom_dt_now=fake_dt_now, + site_id=123456, + ) + + site_obj = Mock() + site_obj.id = 123456 + site_obj.name = "Test Site" + + with ( + patch( + "homeassistant.components.victron_remote_monitoring.coordinator.VictronVRMClient", + autospec=True, + ) as mock_client_cls, + patch( + "homeassistant.components.victron_remote_monitoring.config_flow.VictronVRMClient", + new=mock_client_cls, + ), + ): + client = mock_client_cls.return_value + # installations.stats returns dict used by get_forecast + client.installations.stats = AsyncMock( + return_value={"solar_yield": solar_agg, "consumption": consumption_agg} + ) + # users.* used by config flow + client.users.list_sites = AsyncMock(return_value=[site_obj]) + client.users.get_site = AsyncMock(return_value=site_obj) + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Mock Victron VRM Forecasts 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() + + return mock_config_entry diff --git a/tests/components/victron_remote_monitoring/snapshots/test_sensor.ambr b/tests/components/victron_remote_monitoring/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..422ab254f52 --- /dev/null +++ b/tests/components/victron_remote_monitoring/snapshots/test_sensor.ambr @@ -0,0 +1,1003 @@ +# serializer version: 1 +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_current_hour-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_current_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Current hour', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_current_hour', + 'unique_id': '123456|energy_consumption_current_hour', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_current_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Current hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_current_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2503', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_next_hour-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_next_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Next hour', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_next_hour', + 'unique_id': '123456|energy_consumption_next_hour', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_next_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Next hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_next_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0004', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_today-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Today', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_estimate_today', + 'unique_id': '123456|energy_consumption_estimate_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.2507', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_today_remaining-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_today_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Today remaining', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_estimate_today_remaining', + 'unique_id': '123456|energy_consumption_estimate_today_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_today_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Today remaining', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_today_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0004', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_tomorrow-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Tomorrow', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_estimate_tomorrow', + 'unique_id': '123456|energy_consumption_estimate_tomorrow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Tomorrow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5011', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_yesterday-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Yesterday', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_estimate_yesterday', + 'unique_id': '123456|energy_consumption_estimate_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0503', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_current_hour-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_current_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Current hour', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_current_hour', + 'unique_id': '123456|energy_production_current_hour', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_current_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Current hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_current_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2503', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_next_hour-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_next_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Next hour', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_next_hour', + 'unique_id': '123456|energy_production_next_hour', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_next_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Next hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_next_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0004', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_today-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Today', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_estimate_today', + 'unique_id': '123456|energy_production_estimate_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.2507', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_today_remaining-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_today_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Today remaining', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_estimate_today_remaining', + 'unique_id': '123456|energy_production_estimate_today_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_today_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Today remaining', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_today_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0004', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_tomorrow-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Tomorrow', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_estimate_tomorrow', + 'unique_id': '123456|energy_production_estimate_tomorrow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Tomorrow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5011', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_yesterday-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': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Yesterday', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_estimate_yesterday', + 'unique_id': '123456|energy_production_estimate_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0503', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_today-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.victron_remote_monitoring_highest_consumption_peak_time_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest consumption peak time - Today', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_highest_peak_time_today', + 'unique_id': '123456|consumption_highest_peak_time_today', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest consumption peak time - Today', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-24T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_tomorrow-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.victron_remote_monitoring_highest_consumption_peak_time_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest consumption peak time - Tomorrow', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_highest_peak_time_tomorrow', + 'unique_id': '123456|consumption_highest_peak_time_tomorrow', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest consumption peak time - Tomorrow', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-25T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_yesterday-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.victron_remote_monitoring_highest_consumption_peak_time_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest consumption peak time - Yesterday', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_highest_peak_time_yesterday', + 'unique_id': '123456|consumption_highest_peak_time_yesterday', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest consumption peak time - Yesterday', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-23T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_today-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.victron_remote_monitoring_highest_peak_time_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest peak time - Today', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_highest_peak_time_today', + 'unique_id': '123456|power_highest_peak_time_today', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest peak time - Today', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-24T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_tomorrow-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.victron_remote_monitoring_highest_peak_time_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest peak time - Tomorrow', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_highest_peak_time_tomorrow', + 'unique_id': '123456|power_highest_peak_time_tomorrow', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest peak time - Tomorrow', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-25T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_yesterday-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.victron_remote_monitoring_highest_peak_time_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest peak time - Yesterday', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_highest_peak_time_yesterday', + 'unique_id': '123456|power_highest_peak_time_yesterday', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest peak time - Yesterday', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-23T10:00:00+00:00', + }) +# --- diff --git a/tests/components/victron_remote_monitoring/test_config_flow.py b/tests/components/victron_remote_monitoring/test_config_flow.py new file mode 100644 index 00000000000..610c288f4c2 --- /dev/null +++ b/tests/components/victron_remote_monitoring/test_config_flow.py @@ -0,0 +1,326 @@ +"""Test the Victron VRM Solar Forecast config flow.""" + +from unittest.mock import AsyncMock, Mock + +import pytest +from victron_vrm.exceptions import AuthenticationError, VictronVRMError + +from homeassistant.components.victron_remote_monitoring.config_flow import SiteNotFound +from homeassistant.components.victron_remote_monitoring.const import ( + CONF_API_TOKEN, + CONF_SITE_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +def _make_site(site_id: int, name: str = "ESS System") -> Mock: + """Return a mock site object exposing id and name attributes. + + Using a mock (instead of SimpleNamespace) helps ensure tests rely only on + the attributes we explicitly define and will surface unexpected attribute + access via mock assertions if the implementation changes. + """ + site = Mock() + site.id = site_id + site.name = name + return site + + +async def test_full_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_vrm_client: AsyncMock +) -> None: + """Test the 2-step flow: token -> select site -> create entry.""" + site1 = _make_site(123456, "ESS") + site2 = _make_site(987654, "Cabin") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_vrm_client.users.list_sites = AsyncMock(return_value=[site2, site1]) + mock_vrm_client.users.get_site = AsyncMock(return_value=site1) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "test_token"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_site" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SITE_ID: str(site1.id)} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"VRM for {site1.name}" + assert result["data"] == { + CONF_API_TOKEN: "test_token", + CONF_SITE_ID: site1.id, + } + assert mock_setup_entry.call_count == 1 + + +async def test_user_step_no_sites( + hass: HomeAssistant, mock_vrm_client: AsyncMock +) -> None: + """No sites available keeps user step with no_sites error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + # Reuse existing async mock instead of replacing it + mock_vrm_client.users.list_sites.return_value = [] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_sites"} + + # Provide a site afterwards and resubmit to complete the flow + site = _make_site(999999, "Only Site") + mock_vrm_client.users.list_sites.return_value = [site] + mock_vrm_client.users.list_sites.side_effect = ( + None # ensure no leftover side effect + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token"} + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {CONF_API_TOKEN: "token", CONF_SITE_ID: site.id} + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (AuthenticationError("bad", status_code=401), "invalid_auth"), + (VictronVRMError("auth", status_code=401, response_data={}), "invalid_auth"), + ( + VictronVRMError("server", status_code=500, response_data={}), + "cannot_connect", + ), + (ValueError("boom"), "unknown"), + ], +) +async def test_user_step_errors_then_success( + hass: HomeAssistant, + mock_vrm_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test token validation errors (user step) and eventual success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + flow_id = result["flow_id"] + # First call raises/returns error via side_effect, we then clear and set return value + mock_vrm_client.users.list_sites.side_effect = side_effect + result_err = await hass.config_entries.flow.async_configure( + flow_id, {CONF_API_TOKEN: "token"} + ) + assert result_err["type"] is FlowResultType.FORM + assert result_err["step_id"] == "user" + assert result_err["errors"] == {"base": expected_error} + + # Now make it succeed with a single site, which should auto-complete + site = _make_site(24680, "AutoSite") + mock_vrm_client.users.list_sites.side_effect = None + mock_vrm_client.users.list_sites.return_value = [site] + result_ok = await hass.config_entries.flow.async_configure( + flow_id, {CONF_API_TOKEN: "token"} + ) + assert result_ok["type"] is FlowResultType.CREATE_ENTRY + assert result_ok["data"] == { + CONF_API_TOKEN: "token", + CONF_SITE_ID: site.id, + } + + +@pytest.mark.parametrize( + ("side_effect", "return_value", "expected_error"), + [ + (AuthenticationError("ExpiredToken", status_code=403), None, "invalid_auth"), + ( + VictronVRMError("forbidden", status_code=403, response_data={}), + None, + "invalid_auth", + ), + ( + VictronVRMError("Internal server error", status_code=500, response_data={}), + None, + "cannot_connect", + ), + (None, None, "site_not_found"), # get_site returns None + (ValueError("missing"), None, "unknown"), + ], +) +async def test_select_site_errors( + hass: HomeAssistant, + mock_vrm_client: AsyncMock, + side_effect: Exception | None, + return_value: Mock | None, + expected_error: str, +) -> None: + """Parametrized select_site error scenarios.""" + sites = [_make_site(1, "A"), _make_site(2, "B")] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + flow_id = result["flow_id"] + mock_vrm_client.users.list_sites = AsyncMock(return_value=sites) + if side_effect is not None: + mock_vrm_client.users.get_site = AsyncMock(side_effect=side_effect) + else: + mock_vrm_client.users.get_site = AsyncMock(return_value=return_value) + res_intermediate = await hass.config_entries.flow.async_configure( + flow_id, {CONF_API_TOKEN: "token"} + ) + assert res_intermediate["step_id"] == "select_site" + result = await hass.config_entries.flow.async_configure( + flow_id, {CONF_SITE_ID: str(sites[0].id)} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_site" + assert result["errors"] == {"base": expected_error} + + # Fix the error path by making get_site succeed and submit again + good_site = _make_site(sites[0].id, sites[0].name) + mock_vrm_client.users.get_site = AsyncMock(return_value=good_site) + result_success = await hass.config_entries.flow.async_configure( + flow_id, {CONF_SITE_ID: str(sites[0].id)} + ) + assert result_success["type"] is FlowResultType.CREATE_ENTRY + assert result_success["data"] == { + CONF_API_TOKEN: "token", + CONF_SITE_ID: good_site.id, + } + + +async def test_select_site_duplicate_aborts( + hass: HomeAssistant, mock_vrm_client: AsyncMock +) -> None: + """Selecting an already configured site aborts during the select step (multi-site).""" + site_id = 555 + # Existing entry with same site id + + existing = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_TOKEN: "token", CONF_SITE_ID: site_id}, + unique_id=str(site_id), + title="Existing", + ) + existing.add_to_hass(hass) + + # Start flow and reach select_site + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_vrm_client.users.list_sites = AsyncMock( + return_value=[_make_site(site_id, "Dup"), _make_site(777, "Other")] + ) + mock_vrm_client.users.get_site = AsyncMock() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token2"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_site" + + # Selecting the same site should abort before validation (get_site not called) + res_abort = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SITE_ID: str(site_id)} + ) + assert res_abort["type"] is FlowResultType.ABORT + assert res_abort["reason"] == "already_configured" + assert mock_vrm_client.users.get_site.call_count == 0 + + # Start a new flow selecting the other site to finish with a create entry + result_new = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + other_site = _make_site(777, "Other") + mock_vrm_client.users.list_sites = AsyncMock(return_value=[other_site]) + result_new2 = await hass.config_entries.flow.async_configure( + result_new["flow_id"], {CONF_API_TOKEN: "token3"} + ) + assert result_new2["type"] is FlowResultType.CREATE_ENTRY + assert result_new2["data"] == { + CONF_API_TOKEN: "token3", + CONF_SITE_ID: other_site.id, + } + + +async def test_reauth_flow_success( + hass: HomeAssistant, mock_vrm_client: AsyncMock +) -> None: + """Test successful reauthentication with new token.""" + # Existing configured entry + site_id = 123456 + existing = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_TOKEN: "old_token", CONF_SITE_ID: site_id}, + unique_id=str(site_id), + title="Existing", + ) + existing.add_to_hass(hass) + + # Start reauth + result = await existing.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Provide new token; validate by returning the site + site = _make_site(site_id, "ESS") + mock_vrm_client.users.get_site = AsyncMock(return_value=site) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "new_token"} + ) + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + # Data updated + assert existing.data[CONF_API_TOKEN] == "new_token" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (AuthenticationError("bad", status_code=401), "invalid_auth"), + (VictronVRMError("down", status_code=500, response_data={}), "cannot_connect"), + (SiteNotFound(), "site_not_found"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_vrm_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Reauth shows errors when validation fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_TOKEN: "old", CONF_SITE_ID: 555}, + unique_id="555", + title="Existing", + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + mock_vrm_client.users.get_site = AsyncMock(side_effect=side_effect) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "bad"} + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": expected_error} + + # Provide a valid token afterwards to finish the reauth flow successfully + good_site = _make_site(555, "Existing") + mock_vrm_client.users.get_site = AsyncMock(return_value=good_site) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "new_valid"} + ) + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/victron_remote_monitoring/test_init.py b/tests/components/victron_remote_monitoring/test_init.py new file mode 100644 index 00000000000..175753a2b1b --- /dev/null +++ b/tests/components/victron_remote_monitoring/test_init.py @@ -0,0 +1,51 @@ +"""Tests for Victron Remote Monitoring integration setup and auth handling.""" + +from __future__ import annotations + +import pytest +from victron_vrm.exceptions import AuthenticationError, VictronVRMError + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_effect", "expected_state", "expects_reauth"), + [ + ( + AuthenticationError("bad", status_code=401), + ConfigEntryState.SETUP_ERROR, + True, + ), + ( + VictronVRMError("boom", status_code=500, response_data={}), + ConfigEntryState.SETUP_RETRY, + False, + ), + ], +) +async def test_setup_auth_or_connection_error_starts_retry_or_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vrm_client, + side_effect: Exception | None, + expected_state: ConfigEntryState, + expects_reauth: bool, +) -> None: + """Auth errors initiate reauth flow; other errors set entry to retry. + + AuthenticationError should surface as ConfigEntryAuthFailed which marks the entry in SETUP_ERROR and starts a reauth flow. + Generic VictronVRMError should set the entry to SETUP_RETRY without a reauth flow. + """ + mock_config_entry.add_to_hass(hass) + # Override default success behaviour of fixture to raise side effect + mock_vrm_client.installations.stats.side_effect = side_effect + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + flows_list = list(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + assert bool(flows_list) is expects_reauth diff --git a/tests/components/victron_remote_monitoring/test_sensor.py b/tests/components/victron_remote_monitoring/test_sensor.py new file mode 100644 index 00000000000..15be6ad9bac --- /dev/null +++ b/tests/components/victron_remote_monitoring/test_sensor.py @@ -0,0 +1,25 @@ +"""Tests for the VRM Forecasts sensors. + +Consolidates most per-sensor assertions into snapshot-based regression tests. +""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot all VRM sensor states & key attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) diff --git a/tests/components/volvo/__init__.py b/tests/components/volvo/__init__.py index acd608b8d26..39eba5c702c 100644 --- a/tests/components/volvo/__init__.py +++ b/tests/components/volvo/__init__.py @@ -27,6 +27,12 @@ _MODEL_SPECIFIC_RESPONSES = { "vehicle", ], "xc90_petrol_2019": ["commands", "statistics", "vehicle"], + "xc90_phev_2024": [ + "energy_capabilities", + "energy_state", + "statistics", + "vehicle", + ], } diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py index fedd3a6ec3f..92aa563d88a 100644 --- a/tests/components/volvo/conftest.py +++ b/tests/components/volvo/conftest.py @@ -1,6 +1,7 @@ """Define fixtures for Volvo unit tests.""" from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from dataclasses import dataclass from unittest.mock import AsyncMock, patch import pytest @@ -9,6 +10,7 @@ from volvocarsapi.auth import TOKEN_URL from volvocarsapi.models import ( VolvoCarsAvailableCommand, VolvoCarsLocation, + VolvoCarsValueField, VolvoCarsValueStatusField, VolvoCarsVehicle, ) @@ -17,10 +19,16 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.components.volvo.api import VolvoAuth from homeassistant.components.volvo.const import CONF_VIN, DOMAIN from homeassistant.const import CONF_API_KEY, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) from homeassistant.setup import async_setup_component +from homeassistant.util.json import JsonObjectType from . import async_load_fixture_as_json, async_load_fixture_as_value_field from .const import ( @@ -37,6 +45,30 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +@dataclass +class MockApiData: + """Container for mock API data.""" + + vehicle: VolvoCarsVehicle + commands: list[VolvoCarsAvailableCommand] + location: dict[str, VolvoCarsLocation] + availability: dict[str, VolvoCarsValueField] + brakes: dict[str, VolvoCarsValueField] + diagnostics: dict[str, VolvoCarsValueField] + doors: dict[str, VolvoCarsValueField] + energy_capabilities: JsonObjectType + energy_state: dict[str, VolvoCarsValueStatusField] + engine_status: dict[str, VolvoCarsValueField] + engine_warnings: dict[str, VolvoCarsValueField] + fuel_status: dict[str, VolvoCarsValueField] + odometer: dict[str, VolvoCarsValueField] + recharge_status: dict[str, VolvoCarsValueField] + statistics: dict[str, VolvoCarsValueField] + tyres: dict[str, VolvoCarsValueField] + warnings: dict[str, VolvoCarsValueField] + windows: dict[str, VolvoCarsValueField] + + @pytest.fixture(params=[DEFAULT_MODEL]) def full_model(request: pytest.FixtureRequest) -> str: """Define which model to use when running the test. Use as a decorator.""" @@ -65,81 +97,62 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry -@pytest.fixture(autouse=True) -async def mock_api(hass: HomeAssistant, full_model: str) -> AsyncGenerator[AsyncMock]: +@pytest.fixture +async def mock_api( + hass: HomeAssistant, + full_model: str, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials, +) -> AsyncGenerator[VolvoCarsApi]: """Mock the Volvo API.""" + + mock_api_data = await _async_load_mock_api_data(hass, full_model) + + implementation = await async_get_config_entry_implementation( + hass, mock_config_entry + ) + oauth_session = OAuth2Session(hass, mock_config_entry, implementation) + auth = VolvoAuth(aioclient_mock, oauth_session) + api = VolvoCarsApi( + aioclient_mock, + auth, + mock_config_entry.data[CONF_API_KEY], + mock_config_entry.data[CONF_VIN], + ) + with patch( "homeassistant.components.volvo.VolvoCarsApi", - autospec=True, - ) as mock_api: - vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model) - vehicle = VolvoCarsVehicle.from_dict(vehicle_data) - - commands_data = ( - await async_load_fixture_as_json(hass, "commands", full_model) - ).get("data") - commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] - - location_data = await async_load_fixture_as_json(hass, "location", full_model) - location = {"location": VolvoCarsLocation.from_dict(location_data)} - - availability = await async_load_fixture_as_value_field( - hass, "availability", full_model + return_value=api, + ): + api.async_get_brakes_status = AsyncMock(return_value=mock_api_data.brakes) + api.async_get_command_accessibility = AsyncMock( + return_value=mock_api_data.availability ) - brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model) - diagnostics = await async_load_fixture_as_value_field( - hass, "diagnostics", full_model + api.async_get_commands = AsyncMock(return_value=mock_api_data.commands) + api.async_get_diagnostics = AsyncMock(return_value=mock_api_data.diagnostics) + api.async_get_doors_status = AsyncMock(return_value=mock_api_data.doors) + api.async_get_energy_capabilities = AsyncMock( + return_value=mock_api_data.energy_capabilities ) - doors = await async_load_fixture_as_value_field(hass, "doors", full_model) - energy_capabilities = await async_load_fixture_as_json( - hass, "energy_capabilities", full_model + api.async_get_energy_state = AsyncMock(return_value=mock_api_data.energy_state) + api.async_get_engine_status = AsyncMock( + return_value=mock_api_data.engine_status ) - energy_state_data = await async_load_fixture_as_json( - hass, "energy_state", full_model + api.async_get_engine_warnings = AsyncMock( + return_value=mock_api_data.engine_warnings ) - energy_state = { - key: VolvoCarsValueStatusField.from_dict(value) - for key, value in energy_state_data.items() - } - engine_status = await async_load_fixture_as_value_field( - hass, "engine_status", full_model + api.async_get_fuel_status = AsyncMock(return_value=mock_api_data.fuel_status) + api.async_get_location = AsyncMock(return_value=mock_api_data.location) + api.async_get_odometer = AsyncMock(return_value=mock_api_data.odometer) + api.async_get_recharge_status = AsyncMock( + return_value=mock_api_data.recharge_status ) - engine_warnings = await async_load_fixture_as_value_field( - hass, "engine_warnings", full_model - ) - fuel_status = await async_load_fixture_as_value_field( - hass, "fuel_status", full_model - ) - odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model) - recharge_status = await async_load_fixture_as_value_field( - hass, "recharge_status", full_model - ) - statistics = await async_load_fixture_as_value_field( - hass, "statistics", full_model - ) - tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model) - warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model) - windows = await async_load_fixture_as_value_field(hass, "windows", full_model) - - api: VolvoCarsApi = mock_api.return_value - api.async_get_brakes_status = AsyncMock(return_value=brakes) - api.async_get_command_accessibility = AsyncMock(return_value=availability) - api.async_get_commands = AsyncMock(return_value=commands) - api.async_get_diagnostics = AsyncMock(return_value=diagnostics) - api.async_get_doors_status = AsyncMock(return_value=doors) - api.async_get_energy_capabilities = AsyncMock(return_value=energy_capabilities) - api.async_get_energy_state = AsyncMock(return_value=energy_state) - api.async_get_engine_status = AsyncMock(return_value=engine_status) - api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings) - api.async_get_fuel_status = AsyncMock(return_value=fuel_status) - api.async_get_location = AsyncMock(return_value=location) - api.async_get_odometer = AsyncMock(return_value=odometer) - api.async_get_recharge_status = AsyncMock(return_value=recharge_status) - api.async_get_statistics = AsyncMock(return_value=statistics) - api.async_get_tyre_states = AsyncMock(return_value=tyres) - api.async_get_vehicle_details = AsyncMock(return_value=vehicle) - api.async_get_warnings = AsyncMock(return_value=warnings) - api.async_get_window_states = AsyncMock(return_value=windows) + api.async_get_statistics = AsyncMock(return_value=mock_api_data.statistics) + api.async_get_tyre_states = AsyncMock(return_value=mock_api_data.tyres) + api.async_get_vehicle_details = AsyncMock(return_value=mock_api_data.vehicle) + api.async_get_warnings = AsyncMock(return_value=mock_api_data.warnings) + api.async_get_window_states = AsyncMock(return_value=mock_api_data.windows) yield api @@ -183,3 +196,76 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.volvo.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +async def _async_load_mock_api_data( + hass: HomeAssistant, full_model: str +) -> MockApiData: + """Load all mock API data from fixtures.""" + vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model) + vehicle = VolvoCarsVehicle.from_dict(vehicle_data) + + commands_data = ( + await async_load_fixture_as_json(hass, "commands", full_model) + ).get("data") + commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] + + location_data = await async_load_fixture_as_json(hass, "location", full_model) + location = {"location": VolvoCarsLocation.from_dict(location_data)} + + availability = await async_load_fixture_as_value_field( + hass, "availability", full_model + ) + brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model) + diagnostics = await async_load_fixture_as_value_field( + hass, "diagnostics", full_model + ) + doors = await async_load_fixture_as_value_field(hass, "doors", full_model) + energy_capabilities = await async_load_fixture_as_json( + hass, "energy_capabilities", full_model + ) + energy_state_data = await async_load_fixture_as_json( + hass, "energy_state", full_model + ) + energy_state = { + key: VolvoCarsValueStatusField.from_dict(value) + for key, value in energy_state_data.items() + } + engine_status = await async_load_fixture_as_value_field( + hass, "engine_status", full_model + ) + engine_warnings = await async_load_fixture_as_value_field( + hass, "engine_warnings", full_model + ) + fuel_status = await async_load_fixture_as_value_field( + hass, "fuel_status", full_model + ) + odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model) + recharge_status = await async_load_fixture_as_value_field( + hass, "recharge_status", full_model + ) + statistics = await async_load_fixture_as_value_field(hass, "statistics", full_model) + tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model) + warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model) + windows = await async_load_fixture_as_value_field(hass, "windows", full_model) + + return MockApiData( + vehicle=vehicle, + commands=commands, + location=location, + availability=availability, + brakes=brakes, + diagnostics=diagnostics, + doors=doors, + energy_capabilities=energy_capabilities, + energy_state=energy_state, + engine_status=engine_status, + engine_warnings=engine_warnings, + fuel_status=fuel_status, + odometer=odometer, + recharge_status=recharge_status, + statistics=statistics, + tyres=tyres, + warnings=warnings, + windows=windows, + ) diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json index f3aff11585d..8a5545578b9 100644 --- a/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json +++ b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json @@ -9,7 +9,7 @@ "chargerConnectionStatus": { "isSupported": true }, - "chargingStatus": { + "chargingSystemStatus": { "isSupported": true }, "chargingType": { diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json index 3523d51e071..968c759ab27 100644 --- a/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json @@ -9,7 +9,7 @@ "chargerConnectionStatus": { "isSupported": true }, - "chargingStatus": { + "chargingSystemStatus": { "isSupported": true }, "chargingType": { diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json b/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json index 331795f545b..d8aa07ff0bb 100644 --- a/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json +++ b/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json @@ -9,7 +9,7 @@ "chargerConnectionStatus": { "isSupported": true }, - "chargingStatus": { + "chargingSystemStatus": { "isSupported": true }, "chargingType": { diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json b/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json new file mode 100644 index 00000000000..c7a3cdea8c7 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingSystemStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": false + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json b/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json new file mode 100644 index 00000000000..43cecce6c43 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json @@ -0,0 +1,55 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 87.3, + "unit": "percentage", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "electricRange": { + "status": "OK", + "value": 26, + "unit": "miles", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "DISCONNECTED", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingStatus": { + "status": "OK", + "value": "IDLE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingType": { + "status": "OK", + "value": "NONE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "NO_POWER_AVAILABLE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 0, + "unit": "minutes", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "targetBatteryChargeLevel": { + "status": "ERROR", + "code": "ERROR_READING_PROPERTY", + "message": "Failed to retrieve property." + }, + "chargingPower": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json b/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json new file mode 100644 index 00000000000..41da31d0519 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json @@ -0,0 +1,47 @@ +{ + "averageFuelConsumption": { + "value": 2.0, + "unit": "l/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageEnergyConsumption": { + "value": 19.9, + "unit": "kWh/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageFuelConsumptionAutomatic": { + "value": 0.0, + "unit": "l/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageSpeed": { + "value": 47, + "unit": "km/h", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageSpeedAutomatic": { + "value": 37, + "unit": "km/h", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "tripMeterManual": { + "value": 5935.8, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "tripMeterAutomatic": { + "value": 23.7, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "distanceToEmptyTank": { + "value": 804, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "distanceToEmptyBattery": { + "value": 43, + "unit": "km", + "timestamp": "2025-09-05T07:58:14.760Z" + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json b/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json new file mode 100644 index 00000000000..63ea7c965f5 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL/ELECTRIC", + "externalColour": "Crystal White Pearl", + "batteryCapacityKWH": 18.819, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC90", + "upholstery": "null", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/snapshots/test_binary_sensor.ambr b/tests/components/volvo/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2ebbe489088 --- /dev/null +++ b/tests/components/volvo/snapshots/test_binary_sensor.ambr @@ -0,0 +1,8527 @@ +# serializer version: 1 +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_brake_fluid-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_brake_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake fluid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_fluid_level_warning', + 'unique_id': 'yv1abcdefg1234567_brake_fluid_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_brake_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Brake fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_brake_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_brake_light_center-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.volvo_ex30_brake_light_center', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light center', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_center_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_center_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_brake_light_center-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Brake light center', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_brake_light_center', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_brake_light_left-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.volvo_ex30_brake_light_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_left_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_brake_light_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Brake light left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_brake_light_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_brake_light_right-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.volvo_ex30_brake_light_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_right_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_brake_light_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Brake light right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_brake_light_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_coolant_level-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_coolant_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Coolant level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'coolant_level_warning', + 'unique_id': 'yv1abcdefg1234567_coolant_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_coolant_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Coolant level', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_coolant_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_daytime_running_light_left-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.volvo_ex30_daytime_running_light_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daytime running light left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daytime_running_light_left_warning', + 'unique_id': 'yv1abcdefg1234567_daytime_running_light_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_daytime_running_light_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Daytime running light left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_daytime_running_light_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_daytime_running_light_right-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.volvo_ex30_daytime_running_light_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daytime running light right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daytime_running_light_right_warning', + 'unique_id': 'yv1abcdefg1234567_daytime_running_light_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_daytime_running_light_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Daytime running light right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_daytime_running_light_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_door_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_door_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_front_left', + 'unique_id': 'yv1abcdefg1234567_door_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_door_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo EX30 Door front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_door_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_door_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_door_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_front_right', + 'unique_id': 'yv1abcdefg1234567_door_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_door_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo EX30 Door front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_door_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_door_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_door_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_rear_left', + 'unique_id': 'yv1abcdefg1234567_door_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_door_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo EX30 Door rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_door_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_door_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_door_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_rear_right', + 'unique_id': 'yv1abcdefg1234567_door_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_door_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo EX30 Door rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_door_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_fog_light_front-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.volvo_ex30_fog_light_front', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fog light front', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fog_light_front_warning', + 'unique_id': 'yv1abcdefg1234567_fog_light_front_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_fog_light_front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Fog light front', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_fog_light_front', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_fog_light_rear-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.volvo_ex30_fog_light_rear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fog light rear', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fog_light_rear_warning', + 'unique_id': 'yv1abcdefg1234567_fog_light_rear_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_fog_light_rear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Fog light rear', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_fog_light_rear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_hazard_lights-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.volvo_ex30_hazard_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hazard lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hazard_lights_warning', + 'unique_id': 'yv1abcdefg1234567_hazard_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_hazard_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Hazard lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_hazard_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_high_beam_left-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.volvo_ex30_high_beam_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High beam left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_beam_left_warning', + 'unique_id': 'yv1abcdefg1234567_high_beam_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_high_beam_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 High beam left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_high_beam_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_high_beam_right-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.volvo_ex30_high_beam_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High beam right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_beam_right_warning', + 'unique_id': 'yv1abcdefg1234567_high_beam_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_high_beam_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 High beam right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_high_beam_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_hood-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_hood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hood', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hood', + 'unique_id': 'yv1abcdefg1234567_hood', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo EX30 Hood', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_low_beam_left-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.volvo_ex30_low_beam_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low beam left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_beam_left_warning', + 'unique_id': 'yv1abcdefg1234567_low_beam_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_low_beam_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Low beam left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_low_beam_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_low_beam_right-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.volvo_ex30_low_beam_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low beam right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_beam_right_warning', + 'unique_id': 'yv1abcdefg1234567_low_beam_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_low_beam_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Low beam right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_low_beam_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_oil_level-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_oil_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oil_level_warning', + 'unique_id': 'yv1abcdefg1234567_oil_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_oil_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Oil level', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_oil_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_position_light_front_left-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.volvo_ex30_position_light_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_front_left_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_front_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_position_light_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Position light front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_position_light_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_position_light_front_right-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.volvo_ex30_position_light_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_front_right_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_front_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_position_light_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Position light front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_position_light_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_position_light_rear_left-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.volvo_ex30_position_light_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_rear_left_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_rear_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_position_light_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Position light rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_position_light_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_position_light_rear_right-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.volvo_ex30_position_light_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_rear_right_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_rear_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_position_light_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Position light rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_position_light_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_registration_plate_light-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.volvo_ex30_registration_plate_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Registration plate light', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'registration_plate_light_warning', + 'unique_id': 'yv1abcdefg1234567_registration_plate_light_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_registration_plate_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Registration plate light', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_registration_plate_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_reverse_lights-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.volvo_ex30_reverse_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reverse lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reverse_lights_warning', + 'unique_id': 'yv1abcdefg1234567_reverse_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_reverse_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Reverse lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_reverse_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_side_mark_lights-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.volvo_ex30_side_mark_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side mark lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_mark_lights_warning', + 'unique_id': 'yv1abcdefg1234567_side_mark_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_side_mark_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Side mark lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_side_mark_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_sunroof-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sunroof', + 'unique_id': 'yv1abcdefg1234567_sunroof', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo EX30 Sunroof', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tailgate-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_tailgate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tailgate', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tailgate', + 'unique_id': 'yv1abcdefg1234567_tailgate', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tailgate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo EX30 Tailgate', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_tailgate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tank_lid-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_tank_lid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank lid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tank_lid', + 'unique_id': 'yv1abcdefg1234567_tank_lid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tank_lid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo EX30 Tank lid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_tank_lid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tire_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_tire_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_front_left', + 'unique_id': 'yv1abcdefg1234567_tire_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tire_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Tire front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_tire_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tire_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_tire_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_front_right', + 'unique_id': 'yv1abcdefg1234567_tire_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tire_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Tire front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_tire_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tire_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_tire_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_rear_left', + 'unique_id': 'yv1abcdefg1234567_tire_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tire_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Tire rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_tire_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tire_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_tire_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_rear_right', + 'unique_id': 'yv1abcdefg1234567_tire_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_tire_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Tire rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_tire_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_turn_indication_front_left-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.volvo_ex30_turn_indication_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_front_left_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_front_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_turn_indication_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Turn indication front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_turn_indication_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_turn_indication_front_right-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.volvo_ex30_turn_indication_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_front_right_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_front_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_turn_indication_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Turn indication front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_turn_indication_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_turn_indication_rear_left-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.volvo_ex30_turn_indication_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_rear_left_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_rear_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_turn_indication_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Turn indication rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_turn_indication_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_turn_indication_rear_right-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.volvo_ex30_turn_indication_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_rear_right_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_rear_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_turn_indication_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Turn indication rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_turn_indication_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_washer_fluid-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_washer_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer fluid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'washer_fluid_level_warning', + 'unique_id': 'yv1abcdefg1234567_washer_fluid_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_washer_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo EX30 Washer fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_washer_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_window_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_window_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_front_left', + 'unique_id': 'yv1abcdefg1234567_window_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_window_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo EX30 Window front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_window_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_window_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_window_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_front_right', + 'unique_id': 'yv1abcdefg1234567_window_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_window_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo EX30 Window front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_window_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_window_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_window_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_rear_left', + 'unique_id': 'yv1abcdefg1234567_window_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_window_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo EX30 Window rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_window_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_window_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_ex30_window_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_rear_right', + 'unique_id': 'yv1abcdefg1234567_window_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_window_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo EX30 Window rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_window_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_brake_fluid-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': None, + 'entity_id': 'binary_sensor.volvo_s90_brake_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake fluid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_fluid_level_warning', + 'unique_id': 'yv1abcdefg1234567_brake_fluid_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_brake_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Brake fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_brake_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_brake_light_center-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.volvo_s90_brake_light_center', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light center', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_center_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_center_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_brake_light_center-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Brake light center', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_brake_light_center', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_brake_light_left-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.volvo_s90_brake_light_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_left_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_brake_light_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Brake light left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_brake_light_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_brake_light_right-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.volvo_s90_brake_light_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_right_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_brake_light_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Brake light right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_brake_light_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_coolant_level-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': None, + 'entity_id': 'binary_sensor.volvo_s90_coolant_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Coolant level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'coolant_level_warning', + 'unique_id': 'yv1abcdefg1234567_coolant_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_coolant_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Coolant level', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_coolant_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_daytime_running_light_left-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.volvo_s90_daytime_running_light_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daytime running light left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daytime_running_light_left_warning', + 'unique_id': 'yv1abcdefg1234567_daytime_running_light_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_daytime_running_light_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Daytime running light left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_daytime_running_light_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_daytime_running_light_right-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.volvo_s90_daytime_running_light_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daytime running light right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daytime_running_light_right_warning', + 'unique_id': 'yv1abcdefg1234567_daytime_running_light_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_daytime_running_light_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Daytime running light right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_daytime_running_light_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_door_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_s90_door_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_front_left', + 'unique_id': 'yv1abcdefg1234567_door_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_door_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo S90 Door front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_door_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_door_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_s90_door_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_front_right', + 'unique_id': 'yv1abcdefg1234567_door_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_door_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo S90 Door front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_door_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_door_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_s90_door_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_rear_left', + 'unique_id': 'yv1abcdefg1234567_door_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_door_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo S90 Door rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_door_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_door_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_s90_door_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_rear_right', + 'unique_id': 'yv1abcdefg1234567_door_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_door_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo S90 Door rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_door_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_engine_status-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': None, + 'entity_id': 'binary_sensor.volvo_s90_engine_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': 'Engine status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_status', + 'unique_id': 'yv1abcdefg1234567_engine_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_engine_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Volvo S90 Engine status', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_engine_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_fog_light_front-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.volvo_s90_fog_light_front', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fog light front', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fog_light_front_warning', + 'unique_id': 'yv1abcdefg1234567_fog_light_front_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_fog_light_front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Fog light front', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_fog_light_front', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_fog_light_rear-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.volvo_s90_fog_light_rear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fog light rear', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fog_light_rear_warning', + 'unique_id': 'yv1abcdefg1234567_fog_light_rear_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_fog_light_rear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Fog light rear', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_fog_light_rear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_hazard_lights-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.volvo_s90_hazard_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hazard lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hazard_lights_warning', + 'unique_id': 'yv1abcdefg1234567_hazard_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_hazard_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Hazard lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_hazard_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_high_beam_left-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.volvo_s90_high_beam_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High beam left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_beam_left_warning', + 'unique_id': 'yv1abcdefg1234567_high_beam_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_high_beam_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 High beam left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_high_beam_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_high_beam_right-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.volvo_s90_high_beam_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High beam right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_beam_right_warning', + 'unique_id': 'yv1abcdefg1234567_high_beam_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_high_beam_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 High beam right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_high_beam_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_hood-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': None, + 'entity_id': 'binary_sensor.volvo_s90_hood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hood', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hood', + 'unique_id': 'yv1abcdefg1234567_hood', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo S90 Hood', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_low_beam_left-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.volvo_s90_low_beam_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low beam left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_beam_left_warning', + 'unique_id': 'yv1abcdefg1234567_low_beam_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_low_beam_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Low beam left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_low_beam_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_low_beam_right-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.volvo_s90_low_beam_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low beam right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_beam_right_warning', + 'unique_id': 'yv1abcdefg1234567_low_beam_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_low_beam_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Low beam right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_low_beam_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_oil_level-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': None, + 'entity_id': 'binary_sensor.volvo_s90_oil_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oil_level_warning', + 'unique_id': 'yv1abcdefg1234567_oil_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_oil_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Oil level', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_oil_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_position_light_front_left-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.volvo_s90_position_light_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_front_left_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_front_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_position_light_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Position light front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_position_light_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_position_light_front_right-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.volvo_s90_position_light_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_front_right_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_front_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_position_light_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Position light front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_position_light_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_position_light_rear_left-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.volvo_s90_position_light_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_rear_left_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_rear_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_position_light_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Position light rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_position_light_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_position_light_rear_right-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.volvo_s90_position_light_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_rear_right_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_rear_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_position_light_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Position light rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_position_light_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_registration_plate_light-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.volvo_s90_registration_plate_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Registration plate light', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'registration_plate_light_warning', + 'unique_id': 'yv1abcdefg1234567_registration_plate_light_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_registration_plate_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Registration plate light', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_registration_plate_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_reverse_lights-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.volvo_s90_reverse_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reverse lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reverse_lights_warning', + 'unique_id': 'yv1abcdefg1234567_reverse_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_reverse_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Reverse lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_reverse_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_side_mark_lights-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.volvo_s90_side_mark_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side mark lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_mark_lights_warning', + 'unique_id': 'yv1abcdefg1234567_side_mark_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_side_mark_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Side mark lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_side_mark_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_sunroof-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': None, + 'entity_id': 'binary_sensor.volvo_s90_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sunroof', + 'unique_id': 'yv1abcdefg1234567_sunroof', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo S90 Sunroof', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tailgate-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': None, + 'entity_id': 'binary_sensor.volvo_s90_tailgate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tailgate', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tailgate', + 'unique_id': 'yv1abcdefg1234567_tailgate', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tailgate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo S90 Tailgate', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_tailgate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tank_lid-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': None, + 'entity_id': 'binary_sensor.volvo_s90_tank_lid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank lid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tank_lid', + 'unique_id': 'yv1abcdefg1234567_tank_lid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tank_lid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo S90 Tank lid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_tank_lid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tire_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_s90_tire_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_front_left', + 'unique_id': 'yv1abcdefg1234567_tire_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tire_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Tire front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_tire_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tire_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_s90_tire_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_front_right', + 'unique_id': 'yv1abcdefg1234567_tire_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tire_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Tire front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_tire_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tire_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_s90_tire_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_rear_left', + 'unique_id': 'yv1abcdefg1234567_tire_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tire_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Tire rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_tire_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tire_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_s90_tire_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_rear_right', + 'unique_id': 'yv1abcdefg1234567_tire_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_tire_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Tire rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_tire_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_turn_indication_front_left-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.volvo_s90_turn_indication_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_front_left_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_front_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_turn_indication_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Turn indication front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_turn_indication_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_turn_indication_front_right-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.volvo_s90_turn_indication_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_front_right_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_front_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_turn_indication_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Turn indication front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_turn_indication_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_turn_indication_rear_left-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.volvo_s90_turn_indication_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_rear_left_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_rear_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_turn_indication_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Turn indication rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_turn_indication_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_turn_indication_rear_right-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.volvo_s90_turn_indication_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_rear_right_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_rear_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_turn_indication_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Turn indication rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_turn_indication_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_washer_fluid-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': None, + 'entity_id': 'binary_sensor.volvo_s90_washer_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer fluid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'washer_fluid_level_warning', + 'unique_id': 'yv1abcdefg1234567_washer_fluid_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_washer_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo S90 Washer fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_washer_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_window_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_s90_window_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_front_left', + 'unique_id': 'yv1abcdefg1234567_window_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_window_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo S90 Window front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_window_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_window_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_s90_window_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_front_right', + 'unique_id': 'yv1abcdefg1234567_window_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_window_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo S90 Window front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_window_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_window_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_s90_window_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_rear_left', + 'unique_id': 'yv1abcdefg1234567_window_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_window_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo S90 Window rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_window_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_window_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_s90_window_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_rear_right', + 'unique_id': 'yv1abcdefg1234567_window_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[s90_diesel_2018][binary_sensor.volvo_s90_window_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo S90 Window rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_s90_window_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_brake_fluid-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_brake_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake fluid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_fluid_level_warning', + 'unique_id': 'yv1abcdefg1234567_brake_fluid_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_brake_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Brake fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_brake_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_brake_light_center-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.volvo_xc40_brake_light_center', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light center', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_center_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_center_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_brake_light_center-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Brake light center', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_brake_light_center', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_brake_light_left-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.volvo_xc40_brake_light_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_left_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_brake_light_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Brake light left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_brake_light_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_brake_light_right-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.volvo_xc40_brake_light_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_right_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_brake_light_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Brake light right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_brake_light_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_coolant_level-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_coolant_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Coolant level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'coolant_level_warning', + 'unique_id': 'yv1abcdefg1234567_coolant_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_coolant_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Coolant level', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_coolant_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_daytime_running_light_left-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.volvo_xc40_daytime_running_light_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daytime running light left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daytime_running_light_left_warning', + 'unique_id': 'yv1abcdefg1234567_daytime_running_light_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_daytime_running_light_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Daytime running light left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_daytime_running_light_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_daytime_running_light_right-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.volvo_xc40_daytime_running_light_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daytime running light right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daytime_running_light_right_warning', + 'unique_id': 'yv1abcdefg1234567_daytime_running_light_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_daytime_running_light_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Daytime running light right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_daytime_running_light_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_door_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_door_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_front_left', + 'unique_id': 'yv1abcdefg1234567_door_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_door_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC40 Door front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_door_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_door_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_door_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_front_right', + 'unique_id': 'yv1abcdefg1234567_door_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_door_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC40 Door front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_door_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_door_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_door_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_rear_left', + 'unique_id': 'yv1abcdefg1234567_door_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_door_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC40 Door rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_door_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_door_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_door_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_rear_right', + 'unique_id': 'yv1abcdefg1234567_door_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_door_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC40 Door rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_door_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_fog_light_front-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.volvo_xc40_fog_light_front', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fog light front', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fog_light_front_warning', + 'unique_id': 'yv1abcdefg1234567_fog_light_front_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_fog_light_front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Fog light front', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_fog_light_front', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_fog_light_rear-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.volvo_xc40_fog_light_rear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fog light rear', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fog_light_rear_warning', + 'unique_id': 'yv1abcdefg1234567_fog_light_rear_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_fog_light_rear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Fog light rear', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_fog_light_rear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_hazard_lights-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.volvo_xc40_hazard_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hazard lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hazard_lights_warning', + 'unique_id': 'yv1abcdefg1234567_hazard_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_hazard_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Hazard lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_hazard_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_high_beam_left-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.volvo_xc40_high_beam_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High beam left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_beam_left_warning', + 'unique_id': 'yv1abcdefg1234567_high_beam_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_high_beam_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 High beam left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_high_beam_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_high_beam_right-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.volvo_xc40_high_beam_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High beam right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_beam_right_warning', + 'unique_id': 'yv1abcdefg1234567_high_beam_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_high_beam_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 High beam right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_high_beam_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_hood-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_hood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hood', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hood', + 'unique_id': 'yv1abcdefg1234567_hood', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC40 Hood', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_low_beam_left-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.volvo_xc40_low_beam_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low beam left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_beam_left_warning', + 'unique_id': 'yv1abcdefg1234567_low_beam_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_low_beam_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Low beam left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_low_beam_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_low_beam_right-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.volvo_xc40_low_beam_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low beam right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_beam_right_warning', + 'unique_id': 'yv1abcdefg1234567_low_beam_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_low_beam_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Low beam right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_low_beam_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_oil_level-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_oil_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oil_level_warning', + 'unique_id': 'yv1abcdefg1234567_oil_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_oil_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Oil level', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_oil_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_position_light_front_left-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.volvo_xc40_position_light_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_front_left_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_front_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_position_light_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Position light front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_position_light_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_position_light_front_right-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.volvo_xc40_position_light_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_front_right_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_front_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_position_light_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Position light front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_position_light_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_position_light_rear_left-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.volvo_xc40_position_light_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_rear_left_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_rear_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_position_light_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Position light rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_position_light_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_position_light_rear_right-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.volvo_xc40_position_light_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_rear_right_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_rear_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_position_light_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Position light rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_position_light_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_registration_plate_light-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.volvo_xc40_registration_plate_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Registration plate light', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'registration_plate_light_warning', + 'unique_id': 'yv1abcdefg1234567_registration_plate_light_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_registration_plate_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Registration plate light', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_registration_plate_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_reverse_lights-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.volvo_xc40_reverse_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reverse lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reverse_lights_warning', + 'unique_id': 'yv1abcdefg1234567_reverse_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_reverse_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Reverse lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_reverse_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_side_mark_lights-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.volvo_xc40_side_mark_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side mark lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_mark_lights_warning', + 'unique_id': 'yv1abcdefg1234567_side_mark_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_side_mark_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Side mark lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_side_mark_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_sunroof-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sunroof', + 'unique_id': 'yv1abcdefg1234567_sunroof', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC40 Sunroof', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tailgate-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_tailgate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tailgate', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tailgate', + 'unique_id': 'yv1abcdefg1234567_tailgate', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tailgate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC40 Tailgate', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_tailgate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tank_lid-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_tank_lid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank lid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tank_lid', + 'unique_id': 'yv1abcdefg1234567_tank_lid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tank_lid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC40 Tank lid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_tank_lid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tire_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_tire_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_front_left', + 'unique_id': 'yv1abcdefg1234567_tire_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tire_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Tire front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_tire_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tire_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_tire_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_front_right', + 'unique_id': 'yv1abcdefg1234567_tire_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tire_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Tire front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_tire_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tire_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_tire_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_rear_left', + 'unique_id': 'yv1abcdefg1234567_tire_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tire_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Tire rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_tire_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tire_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_tire_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_rear_right', + 'unique_id': 'yv1abcdefg1234567_tire_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_tire_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Tire rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_tire_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_turn_indication_front_left-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.volvo_xc40_turn_indication_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_front_left_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_front_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_turn_indication_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Turn indication front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_turn_indication_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_turn_indication_front_right-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.volvo_xc40_turn_indication_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_front_right_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_front_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_turn_indication_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Turn indication front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_turn_indication_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_turn_indication_rear_left-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.volvo_xc40_turn_indication_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_rear_left_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_rear_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_turn_indication_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Turn indication rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_turn_indication_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_turn_indication_rear_right-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.volvo_xc40_turn_indication_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_rear_right_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_rear_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_turn_indication_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Turn indication rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_turn_indication_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_washer_fluid-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_washer_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer fluid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'washer_fluid_level_warning', + 'unique_id': 'yv1abcdefg1234567_washer_fluid_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_washer_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC40 Washer fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_washer_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_window_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_window_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_front_left', + 'unique_id': 'yv1abcdefg1234567_window_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_window_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC40 Window front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_window_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_window_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_window_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_front_right', + 'unique_id': 'yv1abcdefg1234567_window_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_window_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC40 Window front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_window_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_window_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_window_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_rear_left', + 'unique_id': 'yv1abcdefg1234567_window_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_window_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC40 Window rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_window_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_window_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc40_window_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_rear_right', + 'unique_id': 'yv1abcdefg1234567_window_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_window_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC40 Window rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_window_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_brake_fluid-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_brake_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake fluid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_fluid_level_warning', + 'unique_id': 'yv1abcdefg1234567_brake_fluid_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_brake_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Brake fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_brake_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_brake_light_center-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.volvo_xc90_brake_light_center', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light center', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_center_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_center_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_brake_light_center-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Brake light center', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_brake_light_center', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_brake_light_left-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.volvo_xc90_brake_light_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_left_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_brake_light_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Brake light left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_brake_light_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_brake_light_right-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.volvo_xc90_brake_light_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Brake light right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brake_light_right_warning', + 'unique_id': 'yv1abcdefg1234567_brake_light_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_brake_light_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Brake light right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_brake_light_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_coolant_level-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_coolant_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Coolant level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'coolant_level_warning', + 'unique_id': 'yv1abcdefg1234567_coolant_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_coolant_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Coolant level', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_coolant_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_daytime_running_light_left-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.volvo_xc90_daytime_running_light_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daytime running light left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daytime_running_light_left_warning', + 'unique_id': 'yv1abcdefg1234567_daytime_running_light_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_daytime_running_light_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Daytime running light left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_daytime_running_light_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_daytime_running_light_right-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.volvo_xc90_daytime_running_light_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daytime running light right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daytime_running_light_right_warning', + 'unique_id': 'yv1abcdefg1234567_daytime_running_light_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_daytime_running_light_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Daytime running light right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_daytime_running_light_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_door_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_door_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_front_left', + 'unique_id': 'yv1abcdefg1234567_door_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_door_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC90 Door front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_door_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_door_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_door_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_front_right', + 'unique_id': 'yv1abcdefg1234567_door_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_door_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC90 Door front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_door_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_door_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_door_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_rear_left', + 'unique_id': 'yv1abcdefg1234567_door_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_door_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC90 Door rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_door_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_door_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_door_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_rear_right', + 'unique_id': 'yv1abcdefg1234567_door_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_door_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC90 Door rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_door_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_engine_status-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_engine_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': 'Engine status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_status', + 'unique_id': 'yv1abcdefg1234567_engine_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_engine_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Volvo XC90 Engine status', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_engine_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_fog_light_front-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.volvo_xc90_fog_light_front', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fog light front', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fog_light_front_warning', + 'unique_id': 'yv1abcdefg1234567_fog_light_front_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_fog_light_front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Fog light front', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_fog_light_front', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_fog_light_rear-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.volvo_xc90_fog_light_rear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fog light rear', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fog_light_rear_warning', + 'unique_id': 'yv1abcdefg1234567_fog_light_rear_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_fog_light_rear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Fog light rear', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_fog_light_rear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_hazard_lights-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.volvo_xc90_hazard_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hazard lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hazard_lights_warning', + 'unique_id': 'yv1abcdefg1234567_hazard_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_hazard_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Hazard lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_hazard_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_high_beam_left-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.volvo_xc90_high_beam_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High beam left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_beam_left_warning', + 'unique_id': 'yv1abcdefg1234567_high_beam_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_high_beam_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 High beam left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_high_beam_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_high_beam_right-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.volvo_xc90_high_beam_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High beam right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_beam_right_warning', + 'unique_id': 'yv1abcdefg1234567_high_beam_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_high_beam_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 High beam right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_high_beam_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_hood-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_hood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hood', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hood', + 'unique_id': 'yv1abcdefg1234567_hood', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC90 Hood', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_low_beam_left-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.volvo_xc90_low_beam_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low beam left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_beam_left_warning', + 'unique_id': 'yv1abcdefg1234567_low_beam_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_low_beam_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Low beam left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_low_beam_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_low_beam_right-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.volvo_xc90_low_beam_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low beam right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_beam_right_warning', + 'unique_id': 'yv1abcdefg1234567_low_beam_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_low_beam_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Low beam right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_low_beam_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_oil_level-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_oil_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oil_level_warning', + 'unique_id': 'yv1abcdefg1234567_oil_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_oil_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Oil level', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_oil_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_position_light_front_left-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.volvo_xc90_position_light_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_front_left_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_front_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_position_light_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Position light front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_position_light_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_position_light_front_right-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.volvo_xc90_position_light_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_front_right_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_front_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_position_light_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Position light front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_position_light_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_position_light_rear_left-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.volvo_xc90_position_light_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_rear_left_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_rear_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_position_light_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Position light rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_position_light_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_position_light_rear_right-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.volvo_xc90_position_light_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Position light rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_light_rear_right_warning', + 'unique_id': 'yv1abcdefg1234567_position_light_rear_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_position_light_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Position light rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_position_light_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_registration_plate_light-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.volvo_xc90_registration_plate_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Registration plate light', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'registration_plate_light_warning', + 'unique_id': 'yv1abcdefg1234567_registration_plate_light_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_registration_plate_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Registration plate light', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_registration_plate_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_reverse_lights-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.volvo_xc90_reverse_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reverse lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reverse_lights_warning', + 'unique_id': 'yv1abcdefg1234567_reverse_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_reverse_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Reverse lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_reverse_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_side_mark_lights-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.volvo_xc90_side_mark_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side mark lights', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_mark_lights_warning', + 'unique_id': 'yv1abcdefg1234567_side_mark_lights_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_side_mark_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Side mark lights', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_side_mark_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_sunroof-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sunroof', + 'unique_id': 'yv1abcdefg1234567_sunroof', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC90 Sunroof', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tailgate-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_tailgate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tailgate', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tailgate', + 'unique_id': 'yv1abcdefg1234567_tailgate', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tailgate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC90 Tailgate', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_tailgate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tank_lid-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_tank_lid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank lid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tank_lid', + 'unique_id': 'yv1abcdefg1234567_tank_lid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tank_lid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Volvo XC90 Tank lid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_tank_lid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tire_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_tire_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_front_left', + 'unique_id': 'yv1abcdefg1234567_tire_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tire_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Tire front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_tire_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tire_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_tire_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_front_right', + 'unique_id': 'yv1abcdefg1234567_tire_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tire_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Tire front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_tire_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tire_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_tire_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_rear_left', + 'unique_id': 'yv1abcdefg1234567_tire_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tire_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Tire rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_tire_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tire_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_tire_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tire_rear_right', + 'unique_id': 'yv1abcdefg1234567_tire_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_tire_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Tire rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_tire_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_turn_indication_front_left-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.volvo_xc90_turn_indication_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_front_left_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_front_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_turn_indication_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Turn indication front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_turn_indication_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_turn_indication_front_right-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.volvo_xc90_turn_indication_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_front_right_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_front_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_turn_indication_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Turn indication front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_turn_indication_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_turn_indication_rear_left-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.volvo_xc90_turn_indication_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_rear_left_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_rear_left_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_turn_indication_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Turn indication rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_turn_indication_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_turn_indication_rear_right-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.volvo_xc90_turn_indication_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turn indication rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_indication_rear_right_warning', + 'unique_id': 'yv1abcdefg1234567_turn_indication_rear_right_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_turn_indication_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Turn indication rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_turn_indication_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_washer_fluid-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_washer_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer fluid', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'washer_fluid_level_warning', + 'unique_id': 'yv1abcdefg1234567_washer_fluid_level_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_washer_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Volvo XC90 Washer fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_washer_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_window_front_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_window_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window front left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_front_left', + 'unique_id': 'yv1abcdefg1234567_window_front_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_window_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC90 Window front left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_window_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_window_front_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_window_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window front right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_front_right', + 'unique_id': 'yv1abcdefg1234567_window_front_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_window_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC90 Window front right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_window_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_window_rear_left-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_window_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window rear left', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_rear_left', + 'unique_id': 'yv1abcdefg1234567_window_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_window_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC90 Window rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_window_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_window_rear_right-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': None, + 'entity_id': 'binary_sensor.volvo_xc90_window_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window rear right', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_rear_right', + 'unique_id': 'yv1abcdefg1234567_window_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc90_petrol_2019][binary_sensor.volvo_xc90_window_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Volvo XC90 Window rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc90_window_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 9d709a27fc3..a8c1f10357a 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -4779,3 +4779,1313 @@ 'state': '178.9', }) # --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery-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.volvo_xc90_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery_capacity-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.volvo_xc90_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo XC90 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.819', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_connection_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': 'Charging connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disconnected', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_power_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': 'Charging power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging power status', + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_power_available', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_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': 'Charging status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_battery-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.volvo_xc90_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_tank-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.volvo_xc90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '804', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_service-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.volvo_xc90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_estimated_charging_time-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.volvo_xc90_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_fuel_amount-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.volvo_xc90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo XC90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_odometer-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.volvo_xc90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_target_battery_charge_level-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': None, + 'entity_id': 'sensor.volvo_xc90_target_battery_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_engine_service-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.volvo_xc90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_service-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.volvo_xc90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_fuel_consumption-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.volvo_xc90_trip_automatic_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip automatic average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption_automatic', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption_automatic', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip automatic average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_speed-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.volvo_xc90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_distance-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.volvo_xc90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.7', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_energy_consumption-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.volvo_xc90_trip_manual_average_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.9', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_fuel_consumption-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.volvo_xc90_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_speed-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.volvo_xc90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_distance-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.volvo_xc90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5935.8', + }) +# --- diff --git a/tests/components/volvo/test_binary_sensor.py b/tests/components/volvo/test_binary_sensor.py new file mode 100644 index 00000000000..3d88b32f798 --- /dev/null +++ b/tests/components/volvo/test_binary_sensor.py @@ -0,0 +1,59 @@ +"""Test Volvo binary sensors.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + ["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"], +) +async def test_binary_sensor( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test binary sensor.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await setup_integration() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + [ + "ex30_2024", + "s90_diesel_2018", + "xc40_electric_2024", + "xc60_phev_2020", + "xc90_petrol_2019", + "xc90_phev_2024", + ], +) +async def test_unique_ids( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test binary sensor for unique id's.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await setup_integration() + + assert f"Platform {DOMAIN} does not generate unique IDs" not in caplog.text diff --git a/tests/components/volvo/test_init.py b/tests/components/volvo/test_init.py index e0e6c74b839..e4e08c22f39 100644 --- a/tests/components/volvo/test_init.py +++ b/tests/components/volvo/test_init.py @@ -21,6 +21,7 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.usefixtures("mock_api") async def test_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -38,6 +39,7 @@ async def test_setup( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("mock_api") async def test_token_refresh_success( mock_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, @@ -61,7 +63,6 @@ async def test_token_refresh_success( @pytest.mark.parametrize( ("token_response"), [ - (HTTPStatus.FORBIDDEN), (HTTPStatus.INTERNAL_SERVER_ERROR), (HTTPStatus.NOT_FOUND), ], @@ -80,15 +81,23 @@ async def test_token_refresh_fail( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + ("token_response"), + [ + (HTTPStatus.BAD_REQUEST), + (HTTPStatus.FORBIDDEN), + ], +) async def test_token_refresh_reauth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_integration: Callable[[], Awaitable[bool]], + token_response: HTTPStatus, ) -> None: """Test where token refresh indicates unauthorized.""" - aioclient_mock.post(TOKEN_URL, status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.post(TOKEN_URL, status=token_response) assert not await setup_integration() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index a4b7a787117..05571ff8cac 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.volvo.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -13,6 +14,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( "full_model", [ @@ -21,6 +23,7 @@ from tests.common import MockConfigEntry, snapshot_platform "xc40_electric_2024", "xc60_phev_2020", "xc90_petrol_2019", + "xc90_phev_2024", ], ) async def test_sensor( @@ -38,6 +41,7 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( "full_model", ["xc40_electric_2024"], @@ -54,6 +58,7 @@ async def test_distance_to_empty_battery( assert hass.states.get("sensor.volvo_xc40_distance_to_empty_battery").state == "250" +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( ("full_model", "short_model"), [("ex30_2024", "ex30"), ("xc60_phev_2020", "xc60")], @@ -71,6 +76,7 @@ async def test_skip_invalid_api_fields( assert not hass.states.get(f"sensor.volvo_{short_model}_charging_current_limit") +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( "full_model", ["ex30_2024"], @@ -85,3 +91,28 @@ async def test_charging_power_value( assert await setup_integration() assert hass.states.get("sensor.volvo_ex30_charging_power").state == "0" + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + [ + "ex30_2024", + "s90_diesel_2018", + "xc40_electric_2024", + "xc60_phev_2020", + "xc90_petrol_2019", + "xc90_phev_2024", + ], +) +async def test_unique_ids( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sensor for unique id's.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + assert f"Platform {DOMAIN} does not generate unique IDs" not in caplog.text diff --git a/tests/components/volvooncall/test_config_flow.py b/tests/components/volvooncall/test_config_flow.py index 5268432c17e..206e35dd330 100644 --- a/tests/components/volvooncall/test_config_flow.py +++ b/tests/components/volvooncall/test_config_flow.py @@ -1,9 +1,5 @@ """Test the Volvo On Call config flow.""" -from unittest.mock import Mock, patch - -from aiohttp import ClientResponseError - from homeassistant import config_entries from homeassistant.components.volvooncall.const import DOMAIN from homeassistant.core import HomeAssistant @@ -13,172 +9,27 @@ from tests.common import MockConfigEntry async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" + """Test we get an abort with deprecation message.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert len(result["errors"]) == 0 - - with ( - patch("volvooncall.Connection.get"), - patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - } - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "deprecated" -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - exc = ClientResponseError(Mock(), (), status=401) - - with patch( - "volvooncall.Connection.get", - side_effect=exc, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_flow_already_configured(hass: HomeAssistant) -> None: - """Test we handle a flow that has already been configured.""" - first_entry = MockConfigEntry(domain=DOMAIN, unique_id="test-username") - first_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert len(result["errors"]) == 0 - - with ( - patch("volvooncall.Connection.get"), - patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" - - -async def test_form_other_exception(hass: HomeAssistant) -> None: - """Test we handle other exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "volvooncall.Connection.get", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test that we handle the reauth flow.""" - - first_entry = MockConfigEntry( +async def test_flow_aborts_with_existing_config_entry(hass: HomeAssistant) -> None: + """Test the config flow aborts even with existing config entries.""" + # Create an existing config entry + entry = MockConfigEntry( domain=DOMAIN, - unique_id="test-username", - data={ - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, + title="Volvo On Call", + data={}, ) - first_entry.add_to_hass(hass) + entry.add_to_hass(hass) - result = await first_entry.start_reauth_flow(hass) - - # the first form is just the confirmation prompt - assert result["type"] is FlowResultType.FORM - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, + # New flow should still abort + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - await hass.async_block_till_done() - - # the second form is the user flow where reauth happens - assert result2["type"] is FlowResultType.FORM - - with patch("volvooncall.Connection.get"): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - "username": "test-username", - "password": "test-new-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "deprecated" diff --git a/tests/components/volvooncall/test_init.py b/tests/components/volvooncall/test_init.py new file mode 100644 index 00000000000..a0b65fad659 --- /dev/null +++ b/tests/components/volvooncall/test_init.py @@ -0,0 +1,76 @@ +"""Test the Volvo On Call integration setup.""" + +from homeassistant.components.volvooncall.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_setup_entry_creates_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that setup creates a repair issue.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call", + data={}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + + assert issue is not None + assert issue.severity is ir.IssueSeverity.WARNING + assert issue.translation_key == "volvooncall_deprecated" + + +async def test_unload_entry_removes_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that unloading the last config entry removes the repair issue.""" + first_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call", + data={}, + ) + first_config_entry.add_to_hass(hass) + second_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call second", + data={}, + ) + second_config_entry.add_to_hass(hass) + + # Setup entry + assert await hass.config_entries.async_setup(first_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + # Check that the repair issue was created + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is not None + + # Unload entry (this is the only entry, so issue should be removed) + assert await hass.config_entries.async_remove(first_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + # Check that the repair issue still exists because there's another entry + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is not None + + # Unload entry (this is the only entry, so issue should be removed) + assert await hass.config_entries.async_remove(second_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + # Check that the repair issue was removed + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is None diff --git a/tests/components/vulcan/__init__.py b/tests/components/vulcan/__init__.py deleted file mode 100644 index 6f165c36c36..00000000000 --- a/tests/components/vulcan/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Uonet+ Vulcan integration.""" diff --git a/tests/components/vulcan/fixtures/fake_config_entry_data.json b/tests/components/vulcan/fixtures/fake_config_entry_data.json deleted file mode 100644 index 4dfcd630140..00000000000 --- a/tests/components/vulcan/fixtures/fake_config_entry_data.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "student_id": "123", - "keystore": { - "Certificate": "certificate", - "DeviceModel": "Home Assistant", - "Fingerprint": "fingerprint", - "FirebaseToken": "firebase_token", - "PrivateKey": "private_key" - }, - "account": { - "LoginId": 0, - "RestURL": "", - "UserLogin": "example@example.com", - "UserName": "example@example.com" - } -} diff --git a/tests/components/vulcan/fixtures/fake_student_1.json b/tests/components/vulcan/fixtures/fake_student_1.json deleted file mode 100644 index fef69684550..00000000000 --- a/tests/components/vulcan/fixtures/fake_student_1.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "TopLevelPartition": "", - "Partition": "", - "ClassDisplay": "", - "Unit": { - "Id": 1, - "Symbol": "", - "Short": "", - "RestURL": "", - "Name": "", - "DisplayName": "" - }, - "ConstituentUnit": { - "Id": 1, - "Short": "", - "Name": "", - "Address": "" - }, - "Pupil": { - "Id": 0, - "LoginId": 0, - "LoginValue": "", - "FirstName": "Jan", - "SecondName": "Maciej", - "Surname": "Kowalski", - "Sex": true - }, - "Periods": [], - "State": 0, - "MessageBox": { - "Id": 1, - "GlobalKey": "00000000-0000-0000-0000-000000000000", - "Name": "Test" - } -} diff --git a/tests/components/vulcan/fixtures/fake_student_2.json b/tests/components/vulcan/fixtures/fake_student_2.json deleted file mode 100644 index e5200c12e17..00000000000 --- a/tests/components/vulcan/fixtures/fake_student_2.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "TopLevelPartition": "", - "Partition": "", - "ClassDisplay": "", - "Unit": { - "Id": 1, - "Symbol": "", - "Short": "", - "RestURL": "", - "Name": "", - "DisplayName": "" - }, - "ConstituentUnit": { - "Id": 1, - "Short": "", - "Name": "", - "Address": "" - }, - "Pupil": { - "Id": 1, - "LoginId": 1, - "LoginValue": "", - "FirstName": "Magda", - "SecondName": "", - "Surname": "Kowalska", - "Sex": false - }, - "Periods": [], - "State": 0, - "MessageBox": { - "Id": 1, - "GlobalKey": "00000000-0000-0000-0000-000000000000", - "Name": "Test" - } -} diff --git a/tests/components/vulcan/test_config_flow.py b/tests/components/vulcan/test_config_flow.py deleted file mode 100644 index e0b7c1a4fdc..00000000000 --- a/tests/components/vulcan/test_config_flow.py +++ /dev/null @@ -1,917 +0,0 @@ -"""Test the Uonet+ Vulcan config flow.""" - -import json -from unittest import mock -from unittest.mock import patch - -from vulcan import ( - Account, - ExpiredTokenException, - InvalidPINException, - InvalidSymbolException, - InvalidTokenException, - UnauthorizedCertificateException, -) -from vulcan.model import Student - -from homeassistant import config_entries -from homeassistant.components.vulcan import config_flow, register -from homeassistant.components.vulcan.config_flow import ClientConnectionError, Keystore -from homeassistant.components.vulcan.const import DOMAIN -from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry, async_load_fixture - -fake_keystore = Keystore("", "", "", "", "") -fake_account = Account( - login_id=1, - user_login="example@example.com", - user_name="example@example.com", - rest_url="rest_url", -) - - -async def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" - flow = config_flow.VulcanFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_success( - mock_keystore, mock_account, mock_student, hass: HomeAssistant -) -> None: - """Test a successful config flow initialized by the user.""" - mock_keystore.return_value = fake_keystore - mock_account.return_value = fake_account - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)) - ] - 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"] == "auth" - assert result["errors"] is None - - with patch( - "homeassistant.components.vulcan.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Jan Kowalski" - assert len(mock_setup_entry.mock_calls) == 1 - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_success_with_multiple_students( - mock_keystore, mock_account, mock_student, hass: HomeAssistant -) -> None: - """Test a successful config flow with multiple students.""" - mock_keystore.return_value = fake_keystore - mock_account.return_value = fake_account - mock_student.return_value = [ - Student.load(student) - for student in ( - await async_load_fixture(hass, "fake_student_1.json", DOMAIN), - await async_load_fixture(hass, "fake_student_2.json", DOMAIN), - ) - ] - 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"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_student" - assert result["errors"] == {} - - with patch( - "homeassistant.components.vulcan.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"student": "0"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Jan Kowalski" - assert len(mock_setup_entry.mock_calls) == 1 - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") -async def test_config_flow_reauth_success( - mock_account, mock_keystore, mock_student, hass: HomeAssistant -) -> None: - """Test a successful config flow reauth.""" - mock_keystore.return_value = fake_keystore - mock_account.return_value = fake_account - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)) - ] - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="0", - data={"student_id": "0"}, - ) - entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {} - - with patch( - "homeassistant.components.vulcan.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") -async def test_config_flow_reauth_without_matching_entries( - mock_account, mock_keystore, mock_student, hass: HomeAssistant -) -> None: - """Test a aborted config flow reauth caused by leak of matching entries.""" - mock_keystore.return_value = fake_keystore - mock_account.return_value = fake_account - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)) - ] - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="0", - data={"student_id": "1"}, - ) - entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_matching_entries" - - -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") -async def test_config_flow_reauth_with_errors( - mock_account, mock_keystore, hass: HomeAssistant -) -> None: - """Test reauth config flow with errors.""" - mock_keystore.return_value = fake_keystore - mock_account.return_value = fake_account - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="0", - data={"student_id": "0"}, - ) - entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {} - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=InvalidTokenException, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_token"} - - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=ExpiredTokenException, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "expired_token"} - - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=InvalidPINException, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_pin"} - - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=InvalidSymbolException, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_symbol"} - - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=ClientConnectionError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "cannot_connect"} - - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "unknown"} - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") -async def test_multiple_config_entries( - mock_account, mock_keystore, mock_student, hass: HomeAssistant -) -> None: - """Test a successful config flow for multiple config entries.""" - mock_keystore.return_value = fake_keystore - mock_account.return_value = fake_account - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)) - ] - MockConfigEntry( - domain=DOMAIN, - unique_id="123456", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ), - ).add_to_hass(hass) - await register.register("token", "region", "000000") - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": False}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] is None - - with patch( - "homeassistant.components.vulcan.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Jan Kowalski" - assert len(mock_setup_entry.mock_calls) == 2 - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -async def test_multiple_config_entries_using_saved_credentials( - mock_student, hass: HomeAssistant -) -> None: - """Test a successful config flow for multiple config entries using saved credentials.""" - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)) - ] - MockConfigEntry( - domain=DOMAIN, - unique_id="123456", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ), - ).add_to_hass(hass) - - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - with patch( - "homeassistant.components.vulcan.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Jan Kowalski" - assert len(mock_setup_entry.mock_calls) == 2 - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -async def test_multiple_config_entries_using_saved_credentials_2( - mock_student, hass: HomeAssistant -) -> None: - """Test a successful config flow for multiple config entries using saved credentials (different situation).""" - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)), - Student.load(await async_load_fixture(hass, "fake_student_2.json", DOMAIN)), - ] - MockConfigEntry( - domain=DOMAIN, - unique_id="123456", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ), - ).add_to_hass(hass) - - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_student" - assert result["errors"] == {} - - with patch( - "homeassistant.components.vulcan.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"student": "0"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Jan Kowalski" - assert len(mock_setup_entry.mock_calls) == 2 - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -async def test_multiple_config_entries_using_saved_credentials_3( - mock_student, hass: HomeAssistant -) -> None: - """Test a successful config flow for multiple config entries using saved credentials.""" - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)) - ] - MockConfigEntry( - entry_id="456", - domain=DOMAIN, - unique_id="234567", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ) - | {"student_id": "456"}, - ).add_to_hass(hass) - MockConfigEntry( - entry_id="123", - domain=DOMAIN, - unique_id="123456", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ), - ).add_to_hass(hass) - - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_saved_credentials" - assert result["errors"] is None - - with patch( - "homeassistant.components.vulcan.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"credentials": "123"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Jan Kowalski" - assert len(mock_setup_entry.mock_calls) == 3 - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -async def test_multiple_config_entries_using_saved_credentials_4( - mock_student, hass: HomeAssistant -) -> None: - """Test a successful config flow for multiple config entries using saved credentials (different situation).""" - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)), - Student.load(await async_load_fixture(hass, "fake_student_2.json", DOMAIN)), - ] - MockConfigEntry( - entry_id="456", - domain=DOMAIN, - unique_id="234567", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ) - | {"student_id": "456"}, - ).add_to_hass(hass) - MockConfigEntry( - entry_id="123", - domain=DOMAIN, - unique_id="123456", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ), - ).add_to_hass(hass) - - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_saved_credentials" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"credentials": "123"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_student" - assert result["errors"] == {} - - with patch( - "homeassistant.components.vulcan.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"student": "0"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Jan Kowalski" - assert len(mock_setup_entry.mock_calls) == 3 - - -async def test_multiple_config_entries_without_valid_saved_credentials( - hass: HomeAssistant, -) -> None: - """Test a unsuccessful config flow for multiple config entries without valid saved credentials.""" - MockConfigEntry( - entry_id="456", - domain=DOMAIN, - unique_id="234567", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ) - | {"student_id": "456"}, - ).add_to_hass(hass) - MockConfigEntry( - entry_id="123", - domain=DOMAIN, - unique_id="123456", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ), - ).add_to_hass(hass) - - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - with patch( - "homeassistant.components.vulcan.config_flow.Vulcan.get_students", - side_effect=UnauthorizedCertificateException, - ): - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_saved_credentials" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"credentials": "123"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "expired_credentials"} - - -async def test_multiple_config_entries_using_saved_credentials_with_connections_issues( - hass: HomeAssistant, -) -> None: - """Test a unsuccessful config flow for multiple config entries without valid saved credentials.""" - MockConfigEntry( - entry_id="456", - domain=DOMAIN, - unique_id="234567", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ) - | {"student_id": "456"}, - ).add_to_hass(hass) - MockConfigEntry( - entry_id="123", - domain=DOMAIN, - unique_id="123456", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ), - ).add_to_hass(hass) - - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - with patch( - "homeassistant.components.vulcan.config_flow.Vulcan.get_students", - side_effect=ClientConnectionError, - ): - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_saved_credentials" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"credentials": "123"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_saved_credentials" - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_multiple_config_entries_using_saved_credentials_with_unknown_error( - hass: HomeAssistant, -) -> None: - """Test a unsuccessful config flow for multiple config entries without valid saved credentials.""" - MockConfigEntry( - entry_id="456", - domain=DOMAIN, - unique_id="234567", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ) - | {"student_id": "456"}, - ).add_to_hass(hass) - MockConfigEntry( - entry_id="123", - domain=DOMAIN, - unique_id="123456", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ), - ).add_to_hass(hass) - - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - with patch( - "homeassistant.components.vulcan.config_flow.Vulcan.get_students", - side_effect=Exception, - ): - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_saved_credentials" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"credentials": "123"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "unknown"} - - -@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") -async def test_student_already_exists( - mock_account, mock_keystore, mock_student, hass: HomeAssistant -) -> None: - """Test config entry when student's entry already exists.""" - mock_keystore.return_value = fake_keystore - mock_account.return_value = fake_account - mock_student.return_value = [ - Student.load(await async_load_fixture(hass, "fake_student_1.json", DOMAIN)) - ] - MockConfigEntry( - domain=DOMAIN, - unique_id="0", - data=json.loads( - await async_load_fixture(hass, "fake_config_entry_data.json", DOMAIN) - ) - | {"student_id": "0"}, - ).add_to_hass(hass) - - await register.register("token", "region", "000000") - - 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"] == "add_next_config_entry" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"use_saved_credentials": True}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "all_student_already_configured" - - -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_invalid_token( - mock_keystore, hass: HomeAssistant -) -> None: - """Test a config flow initialized by the user using invalid token.""" - mock_keystore.return_value = fake_keystore - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=InvalidTokenException, - ): - 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"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "3S20000", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "invalid_token"} - - -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_invalid_region( - mock_keystore, hass: HomeAssistant -) -> None: - """Test a config flow initialized by the user using invalid region.""" - mock_keystore.return_value = fake_keystore - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=InvalidSymbolException, - ): - 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"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "3S10000", CONF_REGION: "invalid_region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "invalid_symbol"} - - -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_invalid_pin(mock_keystore, hass: HomeAssistant) -> None: - """Test a config flow initialized by the with invalid pin.""" - mock_keystore.return_value = fake_keystore - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=InvalidPINException, - ): - 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"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "invalid_pin"} - - -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_expired_token( - mock_keystore, hass: HomeAssistant -) -> None: - """Test a config flow initialized by the with expired token.""" - mock_keystore.return_value = fake_keystore - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=ExpiredTokenException, - ): - 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"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "expired_token"} - - -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_connection_error( - mock_keystore, hass: HomeAssistant -) -> None: - """Test a config flow with connection error.""" - mock_keystore.return_value = fake_keystore - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=ClientConnectionError, - ): - 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"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "cannot_connect"} - - -@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") -async def test_config_flow_auth_unknown_error( - mock_keystore, hass: HomeAssistant -) -> None: - """Test a config flow with unknown error.""" - mock_keystore.return_value = fake_keystore - with patch( - "homeassistant.components.vulcan.config_flow.Account.register", - side_effect=Exception, - ): - 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"] == "auth" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: "3S10000", CONF_REGION: "invalid_region", CONF_PIN: "000000"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["errors"] == {"base": "unknown"} diff --git a/tests/components/vultr/__init__.py b/tests/components/vultr/__init__.py deleted file mode 100644 index fb25b7e145e..00000000000 --- a/tests/components/vultr/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the vultr component.""" diff --git a/tests/components/vultr/conftest.py b/tests/components/vultr/conftest.py deleted file mode 100644 index ae0ce9d6886..00000000000 --- a/tests/components/vultr/conftest.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Test configuration for the Vultr tests.""" - -import json -from unittest.mock import patch - -import pytest -from requests_mock import Mocker - -from homeassistant.components import vultr -from homeassistant.core import HomeAssistant - -from .const import VALID_CONFIG - -from tests.common import load_fixture - - -@pytest.fixture(name="valid_config") -def valid_config(hass: HomeAssistant, requests_mock: Mocker) -> None: - """Load a valid config.""" - requests_mock.get( - "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("account_info.json", "vultr"), - ) - - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - # Setup hub - vultr.setup(hass, VALID_CONFIG) diff --git a/tests/components/vultr/const.py b/tests/components/vultr/const.py deleted file mode 100644 index 06bbf2a7483..00000000000 --- a/tests/components/vultr/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Vultr tests.""" - -VALID_CONFIG = {"vultr": {"api_key": "ABCDEFG1234567"}} diff --git a/tests/components/vultr/fixtures/account_info.json b/tests/components/vultr/fixtures/account_info.json deleted file mode 100644 index 89845dff4ce..00000000000 --- a/tests/components/vultr/fixtures/account_info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "balance": "-123.00", - "pending_charges": "3.38", - "last_payment_date": "2017-08-11 15:04:04", - "last_payment_amount": "-10.00" -} diff --git a/tests/components/vultr/fixtures/server_list.json b/tests/components/vultr/fixtures/server_list.json deleted file mode 100644 index 259f2931e7f..00000000000 --- a/tests/components/vultr/fixtures/server_list.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "576965": { - "SUBID": "576965", - "os": "CentOS 6 x64", - "ram": "4096 MB", - "disk": "Virtual 60 GB", - "main_ip": "123.123.123.123", - "vcpu_count": "2", - "location": "New Jersey", - "DCID": "1", - "default_password": "nreqnusibni", - "date_created": "2013-12-19 14:45:41", - "pending_charges": "46.67", - "status": "active", - "cost_per_month": "10.05", - "current_bandwidth_gb": 131.512, - "allowed_bandwidth_gb": "1000", - "netmask_v4": "255.255.255.248", - "gateway_v4": "123.123.123.1", - "power_status": "running", - "server_state": "ok", - "VPSPLANID": "28", - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64", - "v6_networks": [ - { - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64" - } - ], - "label": "my new server", - "internal_ip": "10.99.0.10", - "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", - "auto_backups": "yes", - "tag": "mytag", - "OSID": "127", - "APPID": "0", - "FIREWALLGROUPID": "0" - }, - "123456": { - "SUBID": "123456", - "os": "CentOS 6 x64", - "ram": "4096 MB", - "disk": "Virtual 60 GB", - "main_ip": "192.168.100.50", - "vcpu_count": "2", - "location": "New Jersey", - "DCID": "1", - "default_password": "nreqnusibni", - "date_created": "2014-10-13 14:45:41", - "pending_charges": "3.72", - "status": "active", - "cost_per_month": "73.25", - "current_bandwidth_gb": 957.457, - "allowed_bandwidth_gb": "1000", - "netmask_v4": "255.255.255.248", - "gateway_v4": "123.123.123.1", - "power_status": "halted", - "server_state": "ok", - "VPSPLANID": "28", - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64", - "v6_networks": [ - { - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64" - } - ], - "label": "my failed server", - "internal_ip": "10.99.0.10", - "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", - "auto_backups": "no", - "tag": "mytag", - "OSID": "127", - "APPID": "0", - "FIREWALLGROUPID": "0" - }, - "555555": { - "SUBID": "555555", - "os": "CentOS 7 x64", - "ram": "1024 MB", - "disk": "Virtual 30 GB", - "main_ip": "192.168.250.50", - "vcpu_count": "1", - "location": "London", - "DCID": "7", - "default_password": "password", - "date_created": "2014-10-15 14:45:41", - "pending_charges": "5.45", - "status": "active", - "cost_per_month": "73.25", - "current_bandwidth_gb": 57.457, - "allowed_bandwidth_gb": "100", - "netmask_v4": "255.255.255.248", - "gateway_v4": "123.123.123.1", - "power_status": "halted", - "server_state": "ok", - "VPSPLANID": "28", - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64", - "v6_networks": [ - { - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64" - } - ], - "label": "Another Server", - "internal_ip": "10.99.0.10", - "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", - "auto_backups": "no", - "tag": "mytag", - "OSID": "127", - "APPID": "0", - "FIREWALLGROUPID": "0" - } -} diff --git a/tests/components/vultr/test_binary_sensor.py b/tests/components/vultr/test_binary_sensor.py deleted file mode 100644 index f6b46b54d25..00000000000 --- a/tests/components/vultr/test_binary_sensor.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Test the Vultr binary sensor platform.""" - -import pytest -import voluptuous as vol - -from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import ( - ATTR_ALLOWED_BANDWIDTH, - ATTR_AUTO_BACKUPS, - ATTR_COST_PER_MONTH, - ATTR_CREATED_AT, - ATTR_IPV4_ADDRESS, - ATTR_SUBSCRIPTION_ID, - CONF_SUBSCRIPTION, - binary_sensor as vultr, -) -from homeassistant.const import CONF_NAME, CONF_PLATFORM -from homeassistant.core import HomeAssistant - -CONFIGS = [ - {CONF_SUBSCRIPTION: "576965", CONF_NAME: "A Server"}, - {CONF_SUBSCRIPTION: "123456", CONF_NAME: "Failed Server"}, - {CONF_SUBSCRIPTION: "555555", CONF_NAME: vultr.DEFAULT_NAME}, -] - - -@pytest.mark.usefixtures("valid_config") -def test_binary_sensor(hass: HomeAssistant) -> None: - """Test successful instance.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - # Setup each of our test configs - for config in CONFIGS: - vultr.setup_platform(hass, config, add_entities, None) - - assert len(hass_devices) == 3 - - for device in hass_devices: - # Test pre data retrieval - if device.subscription == "555555": - assert device.name == "Vultr {}" - - device.update() - device_attrs = device.extra_state_attributes - - if device.subscription == "555555": - assert device.name == "Vultr Another Server" - - if device.name == "A Server": - assert device.is_on is True - assert device.device_class == "power" - assert device.state == "on" - assert device.icon == "mdi:server" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "yes" - assert device_attrs[ATTR_IPV4_ADDRESS] == "123.123.123.123" - assert device_attrs[ATTR_COST_PER_MONTH] == "10.05" - assert device_attrs[ATTR_CREATED_AT] == "2013-12-19 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "576965" - elif device.name == "Failed Server": - assert device.is_on is False - assert device.state == "off" - assert device.icon == "mdi:server-off" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "no" - assert device_attrs[ATTR_IPV4_ADDRESS] == "192.168.100.50" - assert device_attrs[ATTR_COST_PER_MONTH] == "73.25" - assert device_attrs[ATTR_CREATED_AT] == "2014-10-13 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "123456" - - -def test_invalid_sensor_config() -> None: - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subs - vultr.PLATFORM_SCHEMA({CONF_PLATFORM: base_vultr.DOMAIN}) - - -@pytest.mark.usefixtures("valid_config") -def test_invalid_sensors(hass: HomeAssistant) -> None: - """Test the VultrBinarySensor fails.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - bad_conf = {} # No subscription - - vultr.setup_platform(hass, bad_conf, add_entities, None) - - bad_conf = { - CONF_NAME: "Missing Server", - CONF_SUBSCRIPTION: "555555", - } # Sub not associated with API key (not in server_list) - - vultr.setup_platform(hass, bad_conf, add_entities, None) diff --git a/tests/components/vultr/test_init.py b/tests/components/vultr/test_init.py deleted file mode 100644 index 8c5ec51f584..00000000000 --- a/tests/components/vultr/test_init.py +++ /dev/null @@ -1,30 +0,0 @@ -"""The tests for the Vultr component.""" - -from copy import deepcopy -import json -from unittest.mock import patch - -from homeassistant import setup -from homeassistant.components import vultr -from homeassistant.core import HomeAssistant - -from .const import VALID_CONFIG - -from tests.common import load_fixture - - -def test_setup(hass: HomeAssistant) -> None: - """Test successful setup.""" - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - response = vultr.setup(hass, VALID_CONFIG) - assert response - - -async def test_setup_no_api_key(hass: HomeAssistant) -> None: - """Test failed setup with missing API Key.""" - conf = deepcopy(VALID_CONFIG) - del conf["vultr"]["api_key"] - assert not await setup.async_setup_component(hass, vultr.DOMAIN, conf) diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py deleted file mode 100644 index 65be23fc168..00000000000 --- a/tests/components/vultr/test_sensor.py +++ /dev/null @@ -1,134 +0,0 @@ -"""The tests for the Vultr sensor platform.""" - -import pytest -import voluptuous as vol - -from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import CONF_SUBSCRIPTION, sensor as vultr -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_PLATFORM, - UnitOfInformation, -) -from homeassistant.core import HomeAssistant - -CONFIGS = [ - { - CONF_NAME: vultr.DEFAULT_NAME, - CONF_SUBSCRIPTION: "576965", - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - }, - { - CONF_NAME: "Server {}", - CONF_SUBSCRIPTION: "123456", - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - }, - { - CONF_NAME: "VPS Charges", - CONF_SUBSCRIPTION: "555555", - CONF_MONITORED_CONDITIONS: ["pending_charges"], - }, -] - - -@pytest.mark.usefixtures("valid_config") -def test_sensor(hass: HomeAssistant) -> None: - """Test the Vultr sensor class and methods.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - for config in CONFIGS: - vultr.setup_platform(hass, config, add_entities, None) - - assert len(hass_devices) == 5 - - tested = 0 - - for device in hass_devices: - # Test pre update - if device.subscription == "576965": - assert device.name == vultr.DEFAULT_NAME - - device.update() - - if ( - device.unit_of_measurement == UnitOfInformation.GIGABYTES - ): # Test Bandwidth Used - if device.subscription == "576965": - assert device.name == "Vultr my new server Current Bandwidth Used" - assert device.icon == "mdi:chart-histogram" - assert device.state == 131.51 - assert device.icon == "mdi:chart-histogram" - tested += 1 - - elif device.subscription == "123456": - assert device.name == "Server Current Bandwidth Used" - assert device.state == 957.46 - tested += 1 - - elif device.unit_of_measurement == "US$": # Test Pending Charges - if device.subscription == "576965": # Default 'Vultr {} {}' - assert device.name == "Vultr my new server Pending Charges" - assert device.icon == "mdi:currency-usd" - assert device.state == 46.67 - assert device.icon == "mdi:currency-usd" - tested += 1 - - elif device.subscription == "123456": # Custom name with 1 {} - assert device.name == "Server Pending Charges" - assert device.state == 3.72 - tested += 1 - - elif device.subscription == "555555": # No {} in name - assert device.name == "VPS Charges" - assert device.state == 5.45 - tested += 1 - - assert tested == 5 - - -def test_invalid_sensor_config() -> None: - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subscription - vultr.PLATFORM_SCHEMA( - { - CONF_PLATFORM: base_vultr.DOMAIN, - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - } - ) - with pytest.raises(vol.Invalid): # Bad monitored_conditions - vultr.PLATFORM_SCHEMA( - { - CONF_PLATFORM: base_vultr.DOMAIN, - CONF_SUBSCRIPTION: "123456", - CONF_MONITORED_CONDITIONS: ["non-existent-condition"], - } - ) - - -@pytest.mark.usefixtures("valid_config") -def test_invalid_sensors(hass: HomeAssistant) -> None: - """Test the VultrSensor fails.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - bad_conf = { - CONF_NAME: "Vultr {} {}", - CONF_SUBSCRIPTION: "", - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - } # No subs at all - - vultr.setup_platform(hass, bad_conf, add_entities, None) - - assert len(hass_devices) == 0 diff --git a/tests/components/vultr/test_switch.py b/tests/components/vultr/test_switch.py deleted file mode 100644 index 14c88d1e878..00000000000 --- a/tests/components/vultr/test_switch.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Test the Vultr switch platform.""" - -from __future__ import annotations - -import json -from unittest.mock import patch - -import pytest -import voluptuous as vol - -from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import ( - ATTR_ALLOWED_BANDWIDTH, - ATTR_AUTO_BACKUPS, - ATTR_COST_PER_MONTH, - ATTR_CREATED_AT, - ATTR_IPV4_ADDRESS, - ATTR_SUBSCRIPTION_ID, - CONF_SUBSCRIPTION, - switch as vultr, -) -from homeassistant.const import CONF_NAME, CONF_PLATFORM -from homeassistant.core import HomeAssistant - -from tests.common import load_fixture - -CONFIGS = [ - {CONF_SUBSCRIPTION: "576965", CONF_NAME: "A Server"}, - {CONF_SUBSCRIPTION: "123456", CONF_NAME: "Failed Server"}, - {CONF_SUBSCRIPTION: "555555", CONF_NAME: vultr.DEFAULT_NAME}, -] - - -@pytest.fixture(name="hass_devices") -def load_hass_devices(hass: HomeAssistant): - """Load a valid config.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - # Setup each of our test configs - for config in CONFIGS: - vultr.setup_platform(hass, config, add_entities, None) - - return hass_devices - - -@pytest.mark.usefixtures("valid_config") -def test_switch(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: - """Test successful instance.""" - - assert len(hass_devices) == 3 - - tested = 0 - - for device in hass_devices: - if device.subscription == "555555": - assert device.name == "Vultr {}" - tested += 1 - - device.update() - device_attrs = device.extra_state_attributes - - if device.subscription == "555555": - assert device.name == "Vultr Another Server" - tested += 1 - - if device.name == "A Server": - assert device.is_on is True - assert device.state == "on" - assert device.icon == "mdi:server" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "yes" - assert device_attrs[ATTR_IPV4_ADDRESS] == "123.123.123.123" - assert device_attrs[ATTR_COST_PER_MONTH] == "10.05" - assert device_attrs[ATTR_CREATED_AT] == "2013-12-19 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "576965" - tested += 1 - - elif device.name == "Failed Server": - assert device.is_on is False - assert device.state == "off" - assert device.icon == "mdi:server-off" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "no" - assert device_attrs[ATTR_IPV4_ADDRESS] == "192.168.100.50" - assert device_attrs[ATTR_COST_PER_MONTH] == "73.25" - assert device_attrs[ATTR_CREATED_AT] == "2014-10-13 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "123456" - tested += 1 - - assert tested == 4 - - -@pytest.mark.usefixtures("valid_config") -def test_turn_on(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: - """Test turning a subscription on.""" - with ( - patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ), - patch("vultr.Vultr.server_start") as mock_start, - ): - for device in hass_devices: - if device.name == "Failed Server": - device.update() - device.turn_on() - - # Turn on - assert mock_start.call_count == 1 - - -@pytest.mark.usefixtures("valid_config") -def test_turn_off(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: - """Test turning a subscription off.""" - with ( - patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ), - patch("vultr.Vultr.server_halt") as mock_halt, - ): - for device in hass_devices: - if device.name == "A Server": - device.update() - device.turn_off() - - # Turn off - assert mock_halt.call_count == 1 - - -def test_invalid_switch_config() -> None: - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subscription - vultr.PLATFORM_SCHEMA({CONF_PLATFORM: base_vultr.DOMAIN}) - - -@pytest.mark.usefixtures("valid_config") -def test_invalid_switches(hass: HomeAssistant) -> None: - """Test the VultrSwitch fails.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - hass_devices.extend(devices) - - bad_conf = {} # No subscription - - vultr.setup_platform(hass, bad_conf, add_entities, None) - - bad_conf = { - CONF_NAME: "Missing Server", - CONF_SUBSCRIPTION: "665544", - } # Sub not associated with API key (not in server_list) - - vultr.setup_platform(hass, bad_conf, add_entities, None) diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py index d11bca524e9..dae11d58409 100644 --- a/tests/components/waze_travel_time/test_init.py +++ b/tests/components/waze_travel_time/test_init.py @@ -62,6 +62,33 @@ async def test_service_get_travel_times(hass: HomeAssistant) -> None: } +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.usefixtures("mock_update", "mock_config") +async def test_service_get_travel_times_empty_response( + hass: HomeAssistant, mock_update +) -> None: + """Test service get_travel_times.""" + mock_update.return_value = [] + response_data = await hass.services.async_call( + "waze_travel_time", + "get_travel_times", + { + "origin": "location1", + "destination": "location2", + "vehicle_type": "car", + "region": "us", + "units": "imperial", + "incl_filter": ["IncludeThis"], + }, + blocking=True, + return_response=True, + ) + assert response_data == {"routes": []} + + @pytest.mark.usefixtures("mock_update") async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: """Test successful migration of entry data.""" diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index 2963db70ad4..74a2d15b9ba 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -333,3 +333,47 @@ async def test_webhook_reload( assert len(events) == 2 assert events[1].data["hello"] == "yo2 world" + + +async def test_webhook_template( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test triggering with a template webhook.""" + # Set up fake cloud + hass.config.components.add("cloud") + + events = [] + + @callback + def store_event(event): + """Help store events.""" + events.append(event) + + hass.bus.async_listen("test_success", store_event) + + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "webhook", + "webhook_id": "webhook-{{ sqrt(9)|round }}", + "local_only": True, + }, + "action": { + "event": "test_success", + "event_data_template": {"hello": "yo {{ trigger.data.hello }}"}, + }, + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + + await client.post("/api/webhook/webhook-3", data={"hello": "world"}) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["hello"] == "yo world" diff --git a/tests/components/websocket_api/snapshots/test_commands.ambr b/tests/components/websocket_api/snapshots/test_commands.ambr index e8ac80e0e24..3117eeeeb11 100644 --- a/tests/components/websocket_api/snapshots/test_commands.ambr +++ b/tests/components/websocket_api/snapshots/test_commands.ambr @@ -17,6 +17,7 @@ 'required': True, 'selector': dict({ 'object': dict({ + 'multiple': False, }), }), }), @@ -71,6 +72,8 @@ 'name': 'Name', 'selector': dict({ 'text': dict({ + 'multiline': False, + 'multiple': False, }), }), }), @@ -81,6 +84,8 @@ 'required': True, 'selector': dict({ 'text': dict({ + 'multiline': False, + 'multiple': False, }), }), }), diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index bffb2959b31..43a4fb0e539 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -718,7 +718,7 @@ async def test_get_services( assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache # 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", @@ -1433,28 +1433,50 @@ async def test_subscribe_unsubscribe_entities( } +@pytest.mark.parametrize("unserializable_states", [[], ["light.cannot_serialize"]]) async def test_subscribe_unsubscribe_entities_specific_entities( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, hass_admin_user: MockUser, + unserializable_states: list[str], ) -> None: """Test subscribe/unsubscribe entities with a list of entity ids.""" + class CannotSerializeMe: + """Cannot serialize this.""" + + def __init__(self) -> None: + """Init cannot serialize this.""" + + for entity_id in unserializable_states: + hass.states.async_set( + entity_id, + "off", + {"color": "red", "cannot_serialize": CannotSerializeMe()}, + ) + hass.states.async_set("light.permitted", "off", {"color": "red"}) - hass.states.async_set("light.not_intrested", "off", {"color": "blue"}) + hass.states.async_set("light.not_interested", "off", {"color": "blue"}) original_state = hass.states.get("light.permitted") assert isinstance(original_state, State) hass_admin_user.groups = [] hass_admin_user.mock_policy( { "entities": { - "entity_ids": {"light.permitted": True, "light.not_intrested": True} + "entity_ids": { + "light.permitted": True, + "light.not_interested": True, + "light.cannot_serialize": True, + } } } ) await websocket_client.send_json_auto_id( - {"type": "subscribe_entities", "entity_ids": ["light.permitted"]} + { + "type": "subscribe_entities", + "entity_ids": ["light.permitted", "light.cannot_serialize"], + } ) msg = await websocket_client.receive_json() @@ -1476,7 +1498,7 @@ async def test_subscribe_unsubscribe_entities_specific_entities( } } } - hass.states.async_set("light.not_intrested", "on", {"effect": "help"}) + hass.states.async_set("light.not_interested", "on", {"effect": "help"}) hass.states.async_set("light.not_permitted", "on") hass.states.async_set("light.permitted", "on", {"color": "blue"}) @@ -1497,12 +1519,28 @@ async def test_subscribe_unsubscribe_entities_specific_entities( } +@pytest.mark.parametrize("unserializable_states", [[], ["light.cannot_serialize"]]) async def test_subscribe_unsubscribe_entities_with_filter( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, hass_admin_user: MockUser, + unserializable_states: list[str], ) -> None: """Test subscribe/unsubscribe entities with an entity filter.""" + + class CannotSerializeMe: + """Cannot serialize this.""" + + def __init__(self) -> None: + """Init cannot serialize this.""" + + for entity_id in unserializable_states: + hass.states.async_set( + entity_id, + "off", + {"color": "red", "cannot_serialize": CannotSerializeMe()}, + ) + hass.states.async_set("switch.not_included", "off") hass.states.async_set("light.include", "off") await websocket_client.send_json_auto_id( @@ -2307,14 +2345,21 @@ async def test_manifest_list( ] +@pytest.mark.parametrize( + "integrations", + [ + ["hue", "websocket_api"], + ["hue", "non_existing", "websocket_api"], + ], +) async def test_manifest_list_specific_integrations( - hass: HomeAssistant, websocket_client + hass: HomeAssistant, websocket_client, integrations: list[str] ) -> None: """Test loading manifests for specific integrations.""" websocket_api = await async_get_integration(hass, "websocket_api") await websocket_client.send_json_auto_id( - {"type": "manifest/list", "integrations": ["hue", "websocket_api"]} + {"type": "manifest/list", "integrations": integrations} ) hue = await async_get_integration(hass, "hue") diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index b4b11d9cf02..2e60e837976 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta +import logging from typing import Any, cast from unittest.mock import patch @@ -20,7 +21,11 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import async_call_logger_set_level, async_fire_time_changed -from tests.typing import MockHAClientWebSocket, WebSocketGenerator +from tests.typing import ( + ClientSessionGenerator, + MockHAClientWebSocket, + WebSocketGenerator, +) @pytest.fixture @@ -400,6 +405,48 @@ async def test_prepare_fail_connection_reset( assert "Connection reset by peer while preparing WebSocket" in caplog.text +async def test_auth_timeout_logs_at_debug( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test auth timeout is logged at debug level not warning.""" + # Setup websocket API + assert await async_setup_component(hass, "websocket_api", {}) + + client = await hass_client() + + # Patch the auth timeout to be very short (0.001 seconds) + with ( + caplog.at_level(logging.DEBUG, "homeassistant.components.websocket_api"), + patch( + "homeassistant.components.websocket_api.http.AUTH_MESSAGE_TIMEOUT", 0.001 + ), + ): + # Try to connect - will timeout quickly since we don't send auth + ws = await client.ws_connect("/api/websocket") + # Wait a bit for the timeout to trigger and cleanup to complete + await asyncio.sleep(0.1) + await ws.close() + await asyncio.sleep(0.1) + + # Check that "Did not receive auth message" is logged at debug, not warning + debug_messages = [ + r.message for r in caplog.records if r.levelno == logging.DEBUG + ] + assert any( + "Disconnected during auth phase: Did not receive auth message" in msg + for msg in debug_messages + ) + + # Check it's NOT logged at warning level + warning_messages = [ + r.message for r in caplog.records if r.levelno >= logging.WARNING + ] + for msg in warning_messages: + assert "Did not receive auth message" not in msg + + async def test_enable_coalesce( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py index ca96ff1f2a9..ce12b98f493 100644 --- a/tests/components/whirlpool/__init__.py +++ b/tests/components/whirlpool/__init__.py @@ -24,6 +24,7 @@ async def init_integration( CONF_REGION: region, CONF_BRAND: brand, }, + unique_id="nobody", ) return await init_integration_with_entry(hass, entry) diff --git a/tests/components/whirlpool/snapshots/test_climate.ambr b/tests/components/whirlpool/snapshots/test_climate.ambr index 58b894d07cb..17b5a0cb860 100644 --- a/tests/components/whirlpool/snapshots/test_climate.ambr +++ b/tests/components/whirlpool/snapshots/test_climate.ambr @@ -6,17 +6,17 @@ 'area_id': None, 'capabilities': dict({ 'fan_modes': list([ - 'auto', - 'high', - 'medium', - 'low', 'off', + 'auto', + 'low', + 'medium', + 'high', ]), 'hvac_modes': list([ + , , , , - , ]), 'max_temp': 30, 'min_temp': 16, @@ -62,18 +62,18 @@ 'current_temperature': 15, 'fan_mode': 'auto', 'fan_modes': list([ - 'auto', - 'high', - 'medium', - 'low', 'off', + 'auto', + 'low', + 'medium', + 'high', ]), 'friendly_name': 'Aircon said1', 'hvac_modes': list([ + , , , , - , ]), 'max_temp': 30, 'min_temp': 16, @@ -101,17 +101,17 @@ 'area_id': None, 'capabilities': dict({ 'fan_modes': list([ - 'auto', - 'high', - 'medium', - 'low', 'off', + 'auto', + 'low', + 'medium', + 'high', ]), 'hvac_modes': list([ + , , , , - , ]), 'max_temp': 30, 'min_temp': 16, @@ -157,18 +157,18 @@ 'current_temperature': 15, 'fan_mode': 'auto', 'fan_modes': list([ - 'auto', - 'high', - 'medium', - 'low', 'off', + 'auto', + 'low', + 'medium', + 'high', ]), 'friendly_name': 'Aircon said2', 'hvac_modes': list([ + , , , , - , ]), 'max_temp': 30, 'min_temp': 16, diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index b48ed46d186..11aecc93d0d 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -51,7 +51,7 @@ 'subentries': list([ ]), 'title': 'Mock Title', - 'unique_id': None, + 'unique_id': '**REDACTED**', 'version': 1, }), }) diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 6157da04256..33fca5aeb08 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -36,7 +36,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback @@ -243,6 +243,41 @@ async def test_service_calls( getattr(mock_instance, expected_call).assert_called_once_with(*expected_args) +@pytest.mark.parametrize( + ("service", "service_data", "request_method"), + [ + (SERVICE_TURN_OFF, {}, "set_power_on"), + (SERVICE_TURN_ON, {}, "set_power_on"), + (SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVACMode.COOL}, "set_mode"), + (SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVACMode.OFF}, "set_power_on"), + (SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: 20}, "set_temp"), + (SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: FAN_AUTO}, "set_fanspeed"), + (SERVICE_SET_SWING_MODE, {ATTR_SWING_MODE: SWING_OFF}, "set_h_louver_swing"), + ], +) +async def test_service_request_failure( + hass: HomeAssistant, + service: str, + service_data: dict, + request_method: str, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, +) -> None: + """Test controlling the entity through service calls.""" + await init_integration(hass) + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) + getattr(mock_instance, request_method).return_value = False + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + + @pytest.mark.parametrize( ("service", "service_data"), [ @@ -327,3 +362,50 @@ async def test_service_unsupported( {ATTR_ENTITY_ID: entity_id, **service_data}, blocking=True, ) + + +async def test_availability_logs( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, +) -> None: + """Test that availability status changes are logged correctly.""" + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + caplog.clear() + mock_instance.get_online.return_value = True + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state != STATE_UNAVAILABLE + + # Make the entity go offline - should log unavailable message + mock_instance.get_online.return_value = False + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == STATE_UNAVAILABLE + unavailable_log = f"The entity {entity_id} is unavailable" + assert unavailable_log in caplog.text + + # Clear logs and update the offline entity again - should NOT log again + caplog.clear() + state = await update_ac_state(hass, entity_id, mock_instance) + assert unavailable_log not in caplog.text + + # Now bring the entity back online - should log back online message + mock_instance.get_online.return_value = True + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state != STATE_UNAVAILABLE + available_log = f"The entity {entity_id} is back online" + assert available_log in caplog.text + + # Clear logs and make update again - should NOT log again + caplog.clear() + state = await update_ac_state(hass, entity_id, mock_instance) + assert available_log not in caplog.text + + # Test offline again to ensure the flag resets properly + mock_instance.get_online.return_value = False + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == STATE_UNAVAILABLE + assert unavailable_log in caplog.text diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 848a77c6b9e..463ed305d2e 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +import pytest from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region @@ -86,17 +87,24 @@ async def test_setup_no_appliances( assert len(hass.states.async_all()) == 0 -async def test_setup_http_exception( +@pytest.mark.parametrize( + ("exception", "expected_entry_state"), + [ + (aiohttp.ClientConnectionError(), ConfigEntryState.SETUP_RETRY), + (AccountLockedError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_auth_exception( hass: HomeAssistant, mock_auth_api: MagicMock, + exception: Exception, + expected_entry_state: ConfigEntryState, ) -> None: - """Test setup with an http exception.""" - mock_auth_api.return_value.do_auth = AsyncMock( - side_effect=aiohttp.ClientConnectionError() - ) + """Test setup with an exception during authentication.""" + mock_auth_api.return_value.do_auth.side_effect = exception entry = await init_integration(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is expected_entry_state async def test_setup_auth_failed( @@ -111,17 +119,6 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_setup_auth_account_locked( - hass: HomeAssistant, - mock_auth_api: MagicMock, -) -> None: - """Test setup with failed auth due to account being locked.""" - mock_auth_api.return_value.do_auth.side_effect = AccountLockedError - entry = await init_integration(hass) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.SETUP_ERROR - - async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, diff --git a/tests/components/wled/test_analytics.py b/tests/components/wled/test_analytics.py new file mode 100644 index 00000000000..7b392c22180 --- /dev/null +++ b/tests/components/wled/test_analytics.py @@ -0,0 +1,31 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.wled import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={(DOMAIN, "test")}, + manufacturer="Test Manufacturer", + ) + + result = await async_devices_payload(hass) + assert DOMAIN not in result["integrations"] diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py index dc648dafcc2..97326773dc0 100644 --- a/tests/components/wmspro/conftest.py +++ b/tests/components/wmspro/conftest.py @@ -70,6 +70,18 @@ def mock_hub_configuration_prod_awning_dimmer() -> Generator[AsyncMock]: yield mock_hub_configuration +@pytest.fixture +def mock_hub_configuration_prod_awning_valance() -> Generator[AsyncMock]: + """Override WebControlPro._getConfiguration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture( + "config_prod_awning_valance.json", DOMAIN + ), + ) as mock_hub_configuration: + yield mock_hub_configuration + + @pytest.fixture def mock_hub_configuration_prod_roller_shutter() -> Generator[AsyncMock]: """Override WebControlPro._getConfiguration.""" @@ -114,6 +126,16 @@ def mock_hub_status_prod_roller_shutter() -> Generator[AsyncMock]: yield mock_hub_status +@pytest.fixture +def mock_hub_status_prod_valance() -> Generator[AsyncMock]: + """Override WebControlPro._getStatus.""" + with patch( + "wmspro.webcontrol.WebControlPro._getStatus", + return_value=load_json_object_fixture("status_prod_valance.json", DOMAIN), + ) as mock_hub_status: + yield mock_hub_status + + @pytest.fixture def mock_dest_refresh() -> Generator[AsyncMock]: """Override Destination.refresh.""" diff --git a/tests/components/wmspro/fixtures/config_prod_awning_valance.json b/tests/components/wmspro/fixtures/config_prod_awning_valance.json new file mode 100644 index 00000000000..3196d293354 --- /dev/null +++ b/tests/components/wmspro/fixtures/config_prod_awning_valance.json @@ -0,0 +1,46 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 58717, + "animationType": 1, + "names": ["Markise", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 0, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 2, + "actionType": 0, + "actionDescription": 1, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 62571, + "name": "Raum 0", + "destinations": [58717], + "scenes": [] + } + ], + "scenes": [] +} diff --git a/tests/components/wmspro/fixtures/status_prod_valance.json b/tests/components/wmspro/fixtures/status_prod_valance.json new file mode 100644 index 00000000000..38fd4054689 --- /dev/null +++ b/tests/components/wmspro/fixtures/status_prod_valance.json @@ -0,0 +1,28 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 58717, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 0, + "value": { + "percentage": 100 + } + }, + { + "actionId": 2, + "value": { + "percentage": 100 + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py index f28d7f849ef..72b251223dd 100644 --- a/tests/components/wmspro/test_cover.py +++ b/tests/components/wmspro/test_cover.py @@ -81,6 +81,11 @@ async def test_cover_update( "mock_hub_status_prod_awning", "cover.markise", ), + ( + "mock_hub_configuration_prod_awning_valance", + "mock_hub_status_prod_valance", + "cover.markise_2", + ), ( "mock_hub_configuration_prod_roller_shutter", "mock_hub_status_prod_roller_shutter", @@ -159,6 +164,11 @@ async def test_cover_open_and_close( "mock_hub_status_prod_awning", "cover.markise", ), + ( + "mock_hub_configuration_prod_awning_valance", + "mock_hub_status_prod_valance", + "cover.markise_2", + ), ( "mock_hub_configuration_prod_roller_shutter", "mock_hub_status_prod_roller_shutter", @@ -218,6 +228,11 @@ async def test_cover_open_to_pos( "mock_hub_status_prod_awning", "cover.markise", ), + ( + "mock_hub_configuration_prod_awning_valance", + "mock_hub_status_prod_valance", + "cover.markise_2", + ), ( "mock_hub_configuration_prod_roller_shutter", "mock_hub_status_prod_roller_shutter", diff --git a/tests/components/workday/test_calendar.py b/tests/components/workday/test_calendar.py new file mode 100644 index 00000000000..6aa454c860f --- /dev/null +++ b/tests/components/workday/test_calendar.py @@ -0,0 +1,92 @@ +"""Tests for calendar platform of Workday integration.""" + +from datetime import datetime, timedelta + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + EVENT_END_DATETIME, + EVENT_START_DATETIME, + EVENT_SUMMARY, + SERVICE_GET_EVENTS, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import TEST_CONFIG_WITH_PROVINCE, init_integration + +from tests.common import async_fire_time_changed + +ATTR_END = "end" +ATTR_START = "start" + + +@pytest.mark.parametrize( + "time_zone", ["Asia/Tokyo", "Europe/Berlin", "America/Chicago", "US/Hawaii"] +) +async def test_holiday_calendar_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + time_zone: str, +) -> None: + """Test HolidayCalendarEntity functionality.""" + await hass.config.async_set_time_zone(time_zone) + zone = await dt_util.async_get_time_zone(time_zone) + freezer.move_to(datetime(2023, 1, 1, 0, 1, 1, tzinfo=zone)) # New Years Day + await init_integration(hass, TEST_CONFIG_WITH_PROVINCE) + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + ATTR_ENTITY_ID: "calendar.workday_sensor_calendar", + EVENT_START_DATETIME: dt_util.now(), + EVENT_END_DATETIME: dt_util.now() + timedelta(days=10, hours=1), + }, + blocking=True, + return_response=True, + ) + assert { + ATTR_END: "2023-01-02", + ATTR_START: "2023-01-01", + EVENT_SUMMARY: "Workday Sensor", + } not in response["calendar.workday_sensor_calendar"]["events"] + assert { + ATTR_END: "2023-01-04", + ATTR_START: "2023-01-03", + EVENT_SUMMARY: "Workday Sensor", + } in response["calendar.workday_sensor_calendar"]["events"] + + state = hass.states.get("calendar.workday_sensor_calendar") + assert state is not None + assert state.state == "off" + + freezer.move_to( + datetime(2023, 1, 2, 0, 1, 1, tzinfo=zone) + ) # Day after New Years Day + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Binary sensor added to ensure same state for both entities + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "on" + + state = hass.states.get("calendar.workday_sensor_calendar") + assert state is not None + assert state.state == "on" + + freezer.move_to(datetime(2023, 1, 7, 0, 1, 1, tzinfo=zone)) # Workday + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "off" + + state = hass.states.get("calendar.workday_sensor_calendar") + assert state is not None + assert state.state == "off" diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index c618c5fd830..b9cbde31e54 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components.workday.const import ( CONF_CATEGORY, CONF_EXCLUDES, CONF_OFFSET, + CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, DEFAULT_EXCLUDES, @@ -702,6 +703,53 @@ async def test_form_with_categories(hass: HomeAssistant) -> None: } +async def test_form_with_categories_can_remove_day(hass: HomeAssistant) -> None: + """Test optional categories, days can be removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "CH", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "FR", + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: ["Berchtoldstag"], + CONF_LANGUAGE: "de", + CONF_CATEGORY: [OPTIONAL], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "CH", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "province": "FR", + "remove_holidays": ["Berchtoldstag"], + "language": "de", + "category": ["optional"], + } + + async def test_options_form_removes_subdiv(hass: HomeAssistant) -> None: """Test we get the form in options when removing a configured subdivision.""" diff --git a/tests/components/yale/mocks.py b/tests/components/yale/mocks.py index 03ab3609002..a1df4742df7 100644 --- a/tests/components/yale/mocks.py +++ b/tests/components/yale/mocks.py @@ -48,6 +48,27 @@ from tests.common import MockConfigEntry, load_fixture USER_ID = "a76c25e5-49aa-4c14-cd0c-48a6931e2081" +# Define lock capabilities for specific locks +_LOCK_CAPABILITIES = { + "online_with_unlatch": { + "unlatch": True, + "doorSense": True, + "batteryType": "AA", + }, + "68895DD075A1444FAD4C00B273EEEF28": { # Also online_with_unlatch + "unlatch": True, + "doorSense": True, + "batteryType": "AA", + }, +} + +# Default capabilities for locks not in the dict +_DEFAULT_CAPABILITIES = { + "unlatch": False, + "doorSense": True, + "batteryType": "AA", +} + def _mock_get_config( brand: Brand = Brand.YALE_GLOBAL, jwt: str | None = None @@ -340,6 +361,16 @@ async def make_mock_api( api_instance.async_unlatch = AsyncMock() api_instance.async_add_websocket_subscription = AsyncMock() + # Mock capabilities endpoint + async def mock_get_lock_capabilities(token, serial_number): + """Mock the capabilities endpoint response.""" + capabilities = _LOCK_CAPABILITIES.get(serial_number, _DEFAULT_CAPABILITIES) + return {"lock": capabilities} + + api_instance.async_get_lock_capabilities = AsyncMock( + side_effect=mock_get_lock_capabilities + ) + return api_instance diff --git a/tests/components/yale/test_init.py b/tests/components/yale/test_init.py index c028924199e..ec43c07f1ee 100644 --- a/tests/components/yale/test_init.py +++ b/tests/components/yale/test_init.py @@ -36,7 +36,7 @@ from tests.typing import WebSocketGenerator async def test_yale_api_is_failing(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when yale api is failing.""" - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, authenticate_side_effect=YaleApiError( "offline", ClientResponseError(None, None, status=500) @@ -48,7 +48,7 @@ async def test_yale_api_is_failing(hass: HomeAssistant) -> None: async def test_yale_is_offline(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when yale is offline.""" - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, authenticate_side_effect=TimeoutError ) @@ -57,7 +57,7 @@ async def test_yale_is_offline(hass: HomeAssistant) -> None: async def test_yale_late_auth_failure(hass: HomeAssistant) -> None: """Test we can detect a late auth failure.""" - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, authenticate_side_effect=InvalidAuth( "authfailed", ClientResponseError(None, None, status=401) @@ -174,7 +174,7 @@ async def test_load_unload(hass: HomeAssistant) -> None: yale_operative_lock = await _mock_operative_yale_lock_detail(hass) yale_inoperative_lock = await _mock_inoperative_yale_lock_detail(hass) - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, [yale_operative_lock, yale_inoperative_lock] ) @@ -193,7 +193,7 @@ async def test_load_triggers_ble_discovery( yale_lock_with_key = await _mock_lock_with_offline_key(hass) yale_lock_without_key = await _mock_operative_yale_lock_detail(hass) - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, [yale_lock_with_key, yale_lock_without_key] ) await hass.async_block_till_done() @@ -218,7 +218,7 @@ async def test_device_remove_devices( """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) yale_operative_lock = await _mock_operative_yale_lock_detail(hass) - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, [yale_operative_lock] ) entity = entity_registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index c272036097d..1c518b9ce33 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -61,6 +61,16 @@ async def test_user_step_success(hass: HomeAssistant, slot: int) -> None: assert result["step_id"] == "user" assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "key_slot" + assert result2["errors"] == {} + with ( patch( "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", @@ -70,25 +80,24 @@ async def test_user_step_success(hass: HomeAssistant, slot: int) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: slot, }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name - assert result2["data"] == { + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)" + assert result3["data"] == { CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: slot, } - assert result2["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 @@ -113,6 +122,16 @@ async def test_user_step_from_ignored(hass: HomeAssistant, slot: int) -> None: assert result["step_id"] == "user" assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "key_slot" + assert result2["errors"] == {} + with ( patch( "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", @@ -122,25 +141,24 @@ async def test_user_step_from_ignored(hass: HomeAssistant, slot: int) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: slot, }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name - assert result2["data"] == { + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)" + assert result3["data"] == { CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: slot, } - assert result2["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 @@ -198,37 +216,44 @@ async def test_user_step_invalid_keys(hass: HomeAssistant) -> None: result["flow_id"], { CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, - CONF_KEY: "dog", - CONF_SLOT: 66, }, ) assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {CONF_KEY: "invalid_key_format"} + assert result2["step_id"] == "key_slot" + assert result2["errors"] == {} result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, - CONF_KEY: "qfd51b8621c6a139eaffbedcb846b60f", + CONF_KEY: "dog", CONF_SLOT: 66, }, ) assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "user" + assert result3["step_id"] == "key_slot" assert result3["errors"] == {CONF_KEY: "invalid_key_format"} result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "qfd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + }, + ) + assert result4["type"] is FlowResultType.FORM + assert result4["step_id"] == "key_slot" + assert result4["errors"] == {CONF_KEY: "invalid_key_format"} + + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + { CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 999, }, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "user" - assert result4["errors"] == {CONF_SLOT: "invalid_key_index"} + assert result5["type"] is FlowResultType.FORM + assert result5["step_id"] == "key_slot" + assert result5["errors"] == {CONF_SLOT: "invalid_key_index"} with ( patch( @@ -239,25 +264,24 @@ async def test_user_step_invalid_keys(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result6 = await hass.config_entries.flow.async_configure( + result5["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, ) await hass.async_block_till_done() - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name - assert result5["data"] == { + assert result6["type"] is FlowResultType.CREATE_ENTRY + assert result6["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)" + assert result6["data"] == { CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, } - assert result5["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert result6["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 @@ -274,23 +298,32 @@ async def test_user_step_cannot_connect(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "key_slot" + assert result2["errors"] == {} + with patch( "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", side_effect=BleakError, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "cannot_connect"} + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "key_slot" + assert result3["errors"] == {"base": "cannot_connect"} with ( patch( @@ -301,25 +334,24 @@ async def test_user_step_cannot_connect(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name - assert result3["data"] == { + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)" + assert result4["data"] == { CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, } - assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert result4["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 @@ -336,23 +368,32 @@ async def test_user_step_auth_exception(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "key_slot" + assert result2["errors"] == {} + with patch( "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", side_effect=AuthError, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {CONF_KEY: "invalid_auth"} + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "key_slot" + assert result3["errors"] == {CONF_KEY: "invalid_auth"} with ( patch( @@ -363,25 +404,24 @@ async def test_user_step_auth_exception(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name - assert result3["data"] == { + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)" + assert result4["data"] == { CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, } - assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert result4["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 @@ -398,23 +438,32 @@ async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "key_slot" + assert result2["errors"] == {} + with patch( "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", side_effect=RuntimeError, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "unknown"} + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "key_slot" + assert result3["errors"] == {"base": "unknown"} with ( patch( @@ -425,25 +474,24 @@ async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name - assert result3["data"] == { + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)" + assert result4["data"] == { CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, } - assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert result4["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 @@ -455,7 +503,7 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: data=YALE_ACCESS_LOCK_DISCOVERY_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "key_slot" assert result["errors"] == {} with ( @@ -470,7 +518,6 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, @@ -478,7 +525,7 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name + assert result2["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)" assert result2["data"] == { CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, @@ -563,7 +610,7 @@ async def test_integration_discovery_takes_precedence_over_bluetooth( data=YALE_ACCESS_LOCK_DISCOVERY_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "key_slot" assert result["errors"] == {} flows = list(hass.config_entries.flow._handler_progress_index[DOMAIN]) assert len(flows) == 1 @@ -629,6 +676,60 @@ async def test_integration_discovery_takes_precedence_over_bluetooth( assert len(flows) == 0 +async def test_bluetooth_discovery_with_cached_config( + hass: HomeAssistant, +) -> None: + """Test bluetooth discovery when validated config is already in cache.""" + # First, populate the cache via integration discovery + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + "name": "Front Door", + "address": YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + "key": "2fd51b8621c6a139eaffbedcb846b60f", + "slot": 66, + "serial": "M1XXX012LU", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + # Now do bluetooth discovery with the cached config + with patch( + "homeassistant.components.yalexs_ble.PushLock.validate", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=YALE_ACCESS_LOCK_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "integration_discovery_confirm" + assert result["description_placeholders"] == { + "name": "Front Door", + "address": YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + } + + # Confirm the discovery + with patch( + "homeassistant.components.yalexs_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Front Door" + assert result["data"] == { + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + } + + async def test_integration_discovery_updates_key_unique_local_name( hass: HomeAssistant, ) -> None: @@ -774,7 +875,7 @@ async def test_integration_discovery_takes_precedence_over_bluetooth_uuid_addres data=LOCK_DISCOVERY_INFO_UUID_ADDRESS, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "key_slot" assert result["errors"] == {} flows = list(hass.config_entries.flow._handler_progress_index[DOMAIN]) assert len(flows) == 1 @@ -850,7 +951,7 @@ async def test_integration_discovery_takes_precedence_over_bluetooth_non_unique_ data=OLD_FIRMWARE_LOCK_DISCOVERY_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "key_slot" assert result["errors"] == {} flows = list(hass.config_entries.flow._handler_progress_index[DOMAIN]) assert len(flows) == 1 @@ -907,6 +1008,15 @@ async def test_user_is_setting_up_lock_and_discovery_happens_in_the_middle( assert result["step_id"] == "user" assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "key_slot" + user_flow_event = asyncio.Event() valdidate_started = asyncio.Event() @@ -926,9 +1036,8 @@ async def test_user_is_setting_up_lock_and_discovery_happens_in_the_middle( ): user_flow_task = asyncio.create_task( hass.config_entries.flow.async_configure( - result["flow_id"], + result2["flow_id"], { - CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", CONF_SLOT: 66, }, @@ -959,7 +1068,7 @@ async def test_user_is_setting_up_lock_and_discovery_happens_in_the_middle( user_flow_result = await user_flow_task assert user_flow_result["type"] is FlowResultType.CREATE_ENTRY - assert user_flow_result["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name + assert user_flow_result["title"] == f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} (EEFF)" assert user_flow_result["data"] == { CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, @@ -1033,6 +1142,75 @@ async def test_reauth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_step_with_cached_config(hass: HomeAssistant) -> None: + """Test user step when config is already cached from integration discovery.""" + # First, simulate integration discovery to populate the cache + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + "name": "Front Door", + "address": YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + "key": "2fd51b8621c6a139eaffbedcb846b60f", + "slot": 66, + "serial": "M1XXX012LU", + }, + ) + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "no_devices_found" + + # Now start a user flow - it should use the cached config + with patch( + "homeassistant.components.yalexs_ble.config_flow.async_discovered_service_info", + return_value=[YALE_ACCESS_LOCK_DISCOVERY_INFO], + ): + 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" + + # The dropdown should show "Front Door (AA:BB:CC:DD:EE:FF)" from cached config + # This is the line 346 case we're testing + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "key_slot" + + # The key_slot step should auto-complete with cached values + # When no user input is provided, it should use the cached config + with ( + patch( + "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", + ), + patch( + "homeassistant.components.yalexs_ble.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + # No user input triggers using cached config + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + None, # None triggers checking for cached config + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Front Door" # Uses the name from cached config + assert result3["data"] == { + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + } + assert result3["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_options(hass: HomeAssistant) -> None: """Test options.""" entry = MockConfigEntry( diff --git a/tests/components/zeroconf/test_usage.py b/tests/components/zeroconf/test_usage.py index 2e186bc39d0..35fe0076cac 100644 --- a/tests/components/zeroconf/test_usage.py +++ b/tests/components/zeroconf/test_usage.py @@ -1,5 +1,8 @@ """Test Zeroconf multiple instance protection.""" +from __future__ import annotations + +from typing import Self from unittest.mock import Mock, patch import pytest @@ -20,7 +23,7 @@ class MockZeroconf: def __init__(self, *args, **kwargs) -> None: """Initialize the mock.""" - def __new__(cls, *args, **kwargs) -> "MockZeroconf": + def __new__(cls, *args, **kwargs) -> Self: """Return the shared instance.""" diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index df61fb499d2..a21c6f7ada3 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -1,6 +1,6 @@ """Test configuration for the ZHA component.""" -from collections.abc import Generator +from collections.abc import Callable, Coroutine, Generator import itertools import time from typing import Any @@ -184,6 +184,9 @@ async def zigpy_app_controller(): warnings.simplefilter("ignore", DeprecationWarning) mock_app = _wrap_mock_instance(app) mock_app.backups = _wrap_mock_instance(app.backups) + mock_app._concurrent_requests_semaphore = _wrap_mock_instance( + app._concurrent_requests_semaphore + ) yield mock_app @@ -241,11 +244,11 @@ def setup_zha( hass: HomeAssistant, config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication, -): +) -> Callable[..., Coroutine[None]]: """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} - async def _setup(config=None): + async def _setup(config=None) -> None: config_entry.add_to_hass(hass) config = config or {} @@ -353,7 +356,7 @@ def network_backup() -> zigpy.backups.NetworkBackup: @pytest.fixture -def zigpy_device_mock(zigpy_app_controller): +def zigpy_device_mock(zigpy_app_controller) -> Callable[..., zigpy.device.Device]: """Make a fake device using the specified cluster classes.""" def _mock_dev( diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index 609438cd725..9f4373557c2 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -1,8 +1,10 @@ """Test ZHA alarm control panel.""" +from collections.abc import Callable, Coroutine from unittest.mock import AsyncMock, call, patch, sentinel import pytest +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl import Cluster from zigpy.zcl.clusters import security @@ -45,7 +47,9 @@ def alarm_control_panel_platform_only(): new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), ) async def test_alarm_control_panel( - hass: HomeAssistant, setup_zha, zigpy_device_mock + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA alarm control panel platform.""" diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 7aff6d81f5d..f2eca7207c4 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, call, patch @@ -26,7 +27,7 @@ def required_platform_only(): async def test_async_get_network_settings_active( - hass: HomeAssistant, setup_zha + hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]] ) -> None: """Test reading settings with an active ZHA installation.""" await setup_zha() @@ -36,7 +37,9 @@ async def test_async_get_network_settings_active( async def test_async_get_network_settings_inactive( - hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_app_controller: ControllerApplication, ) -> None: """Test reading settings with an inactive ZHA installation.""" await setup_zha() @@ -63,7 +66,9 @@ async def test_async_get_network_settings_inactive( async def test_async_get_network_settings_missing( - hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_app_controller: ControllerApplication, ) -> None: """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() @@ -86,7 +91,9 @@ async def test_async_get_network_settings_failure(hass: HomeAssistant) -> None: await api.async_get_network_settings(hass) -async def test_async_get_radio_type_active(hass: HomeAssistant, setup_zha) -> None: +async def test_async_get_radio_type_active( + hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]] +) -> None: """Test reading the radio type with an active ZHA installation.""" await setup_zha() @@ -94,7 +101,9 @@ async def test_async_get_radio_type_active(hass: HomeAssistant, setup_zha) -> No assert radio_type == RadioType.ezsp -async def test_async_get_radio_path_active(hass: HomeAssistant, setup_zha) -> None: +async def test_async_get_radio_path_active( + hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]] +) -> None: """Test reading the radio path with an active ZHA installation.""" await setup_zha() @@ -103,7 +112,9 @@ async def test_async_get_radio_path_active(hass: HomeAssistant, setup_zha) -> No async def test_change_channel( - hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_app_controller: ControllerApplication, ) -> None: """Test changing the channel.""" await setup_zha() @@ -113,7 +124,9 @@ async def test_change_channel( async def test_change_channel_auto( - hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_app_controller: ControllerApplication, ) -> None: """Test changing the channel automatically using an energy scan.""" await setup_zha() diff --git a/tests/components/zha/test_backup.py b/tests/components/zha/test_backup.py index dc6c5dc29cb..f477364616a 100644 --- a/tests/components/zha/test_backup.py +++ b/tests/components/zha/test_backup.py @@ -1,5 +1,6 @@ """Unit tests for ZHA backup platform.""" +from collections.abc import Callable, Coroutine from unittest.mock import AsyncMock, patch from zigpy.application import ControllerApplication @@ -9,7 +10,9 @@ from homeassistant.core import HomeAssistant async def test_pre_backup( - hass: HomeAssistant, zigpy_app_controller: ControllerApplication, setup_zha + hass: HomeAssistant, + zigpy_app_controller: ControllerApplication, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test backup creation when `async_pre_backup` is called.""" await setup_zha() @@ -23,13 +26,17 @@ async def test_pre_backup( @patch("homeassistant.components.zha.backup.get_zha_gateway", side_effect=ValueError()) -async def test_pre_backup_no_gateway(hass: HomeAssistant, setup_zha) -> None: +async def test_pre_backup_no_gateway( + hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]] +) -> None: """Test graceful backup failure when no gateway exists.""" await setup_zha() await async_pre_backup(hass) -async def test_post_backup(hass: HomeAssistant, setup_zha) -> None: +async def test_post_backup( + hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]] +) -> None: """Test no-op `async_post_backup`.""" await setup_zha() await async_post_backup(hass) diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index a9765a1b547..a912b9179e0 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -1,8 +1,10 @@ """Test ZHA binary sensor.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general @@ -39,8 +41,8 @@ def binary_sensor_platform_only(): async def test_binary_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA binary_sensor platform.""" await setup_zha() diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index 33ed004312b..8c41a914f5a 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -1,10 +1,12 @@ """Test ZHA button.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch from freezegun import freeze_time import pytest from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general import zigpy.zcl.foundation as zcl_f @@ -44,7 +46,9 @@ def button_platform_only(): @pytest.fixture -async def setup_zha_integration(hass: HomeAssistant, setup_zha): +async def setup_zha_integration( + hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]] +): """Set up ZHA component.""" # if we call this in the test itself the test hangs forever @@ -56,7 +60,7 @@ async def test_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_zha_integration, # pylint: disable=unused-argument - zigpy_device_mock, + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA button platform.""" diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 3425c1eb2b6..27d99ddc320 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -1,5 +1,6 @@ """Test ZHA climate.""" +from collections.abc import Callable, Coroutine from typing import Literal from unittest.mock import patch @@ -7,6 +8,7 @@ import pytest from zha.application.platforms.climate.const import HVAC_MODE_2_SYSTEM, SEQ_OF_OPERATION import zhaquirks.sinope.thermostat import zhaquirks.tuya.ts0601_trv +from zigpy.device import Device import zigpy.profiles from zigpy.profiles import zha import zigpy.types @@ -147,7 +149,11 @@ def climate_platform_only(): @pytest.fixture -def device_climate_mock(hass: HomeAssistant, setup_zha, zigpy_device_mock): +def device_climate_mock( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +): """Test regular thermostat device.""" async def _dev(clusters, plug=None, manuf=None, quirk=None): diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 94566be2f87..aae16dbccfb 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,12 +1,18 @@ """Tests for ZHA config flow.""" from collections.abc import Callable, Coroutine, Generator -import copy from datetime import timedelta from ipaddress import ip_address import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch +from unittest.mock import ( + AsyncMock, + MagicMock, + PropertyMock, + call, + create_autospec, + patch, +) import uuid import pytest @@ -16,7 +22,11 @@ from zigpy.backups import BackupManager import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, SCHEMA_DEVICE import zigpy.device -from zigpy.exceptions import NetworkNotFormed +from zigpy.exceptions import ( + CannotWriteNetworkSettings, + DestructiveWriteNetworkSettings, + NetworkNotFormed, +) import zigpy.types from homeassistant import config_entries @@ -29,7 +39,7 @@ from homeassistant.components.zha.const import ( DOMAIN, EZSP_OVERWRITE_EUI64, ) -from homeassistant.components.zha.radio_manager import ProbeResult +from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ( SOURCE_SSDP, SOURCE_USB, @@ -150,7 +160,7 @@ def mock_detect_radio_type( def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: """Mock of a serial port.""" - port = ListPortInfo("/dev/ttyUSB1234") + port = ListPortInfo(device) port.serial_number = "1234" port.manufacturer = "Virtual serial port" port.device = device @@ -268,11 +278,11 @@ async def test_zeroconf_discovery( ) assert result_confirm["type"] is FlowResultType.MENU - assert result_confirm["step_id"] == "choose_formation_strategy" + assert result_confirm["step_id"] == "choose_setup_strategy" result_form = await hass.config_entries.flow.async_configure( result_confirm["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -322,11 +332,11 @@ async def test_legacy_zeroconf_discovery_zigate( ) assert result_confirm["type"] is FlowResultType.MENU - assert result_confirm["step_id"] == "choose_formation_strategy" + assert result_confirm["step_id"] == "choose_setup_strategy" result_form = await hass.config_entries.flow.async_configure( result_confirm["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -426,9 +436,9 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( flow["flow_id"], user_input={} ) - # Config will fail - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + # Now prompts to migrate instead of aborting + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choose_setup_strategy" @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) @@ -456,12 +466,12 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" + assert result2["step_id"] == "choose_setup_strategy" with patch("homeassistant.components.zha.async_setup_entry", return_value=True): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -477,56 +487,6 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: } -@patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=True) -async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None: - """Test zigate usb flow -- radio detected.""" - discovery_info = UsbServiceInfo( - device="/dev/ttyZIGBEE", - pid="0403", - vid="6015", - serial_number="1234", - description="zigate radio", - manufacturer="test", - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USB}, data=discovery_info - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result2["step_id"] == "verify_radio" - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "choose_formation_strategy" - - with patch("homeassistant.components.zha.async_setup_entry", return_value=True): - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, - ) - await hass.async_block_till_done() - - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "zigate radio" - assert result4["data"] == { - "device": { - "path": "/dev/ttyZIGBEE", - "baudrate": 115200, - "flow_control": None, - }, - CONF_RADIO_TYPE: "zigate", - } - - @patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", AsyncMock(return_value=ProbeResult.PROBING_FAILED), @@ -574,18 +534,182 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: description="zigbee radio", manufacturer="test", ) - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USB}, data=discovery_info ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={}, + ) + + # When we have an existing config entry, we migrate + assert confirm_result["type"] is FlowResultType.MENU + assert confirm_result["step_id"] == "choose_migration_strategy" + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_strategy_recommended( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test automatic migration.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, + ) + entry.add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ): + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup, + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + return_value=True, + ) as mock_async_unload, + ): + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert mock_async_unload.mock_calls == [call(entry.entry_id)] + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "reconfigure_successful" + mock_restore_backup.assert_called_once() + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_strategy_recommended_cannot_write( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test recommended migration with a write failure.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}, + CONF_RADIO_TYPE: "ezsp", + }, + ).add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ): + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=CannotWriteNetworkSettings("test error"), + ) as mock_restore_backup: + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert mock_restore_backup.call_count == 1 + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "cannot_restore_backup" + assert "test error" in result_recommended["description_placeholders"]["error"] + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_multiple_zha_entries_aborts(hass: HomeAssistant, mock_app) -> None: + """Test flow aborts if there are multiple ZHA config entries.""" + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB2"}} + ).add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_ADVANCED}, + ) + + result_reuse = await hass.config_entries.flow.async_configure( + result_recommended["flow_id"], + user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + ) + + assert result_reuse["type"] is FlowResultType.ABORT + assert result_reuse["reason"] == "single_instance_allowed" @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> None: - """Test usb flow already set up and the path does not change.""" +async def test_discovery_via_usb_duplicate_unique_id(hass: HomeAssistant) -> None: + """Test USB discovery when a config entry with a duplicate unique_id already exists.""" entry = MockConfigEntry( domain=DOMAIN, @@ -613,13 +737,8 @@ async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> No ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert entry.data[CONF_DEVICE] == { - CONF_DEVICE_PATH: "/dev/ttyUSB1", - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - } + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) @@ -733,6 +852,40 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non } +async def test_discovery_via_usb_same_device_already_setup(hass: HomeAssistant) -> None: + """Test discovery aborting if ZHA is already setup.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/serial/by-id/usb-device123"}}, + ).add_to_hass(hass) + + # Discovery info with the same device but different path format + discovery_info = UsbServiceInfo( + device="/dev/ttyUSB0", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + with patch( + "homeassistant.components.zha.config_flow.usb.get_serial_by_id", + return_value="/dev/serial/by-id/usb-device123", + ) as mock_get_serial_by_id: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + # Verify get_serial_by_id was called to normalize the path + assert mock_get_serial_by_id.mock_calls == [call("/dev/ttyUSB0")] + + # Should abort since it's the same device + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: @@ -751,11 +904,50 @@ async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> N domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} ).add_to_hass(hass) + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info + ) + await hass.async_block_till_done() + + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={}, + ) + + # When we have an existing config entry, we migrate + assert confirm_result["type"] is FlowResultType.MENU + assert confirm_result["step_id"] == "choose_migration_strategy" + + +async def test_zeroconf_discovery_via_socket_already_setup_with_ip_match( + hass: HomeAssistant, +) -> None: + """Test zeroconf discovery aborting when ZHA is already setup with socket and one IP matches.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: {CONF_DEVICE_PATH: "socket://192.168.1.101:6638"}}, + ).add_to_hass(hass) + + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ + ip_address("192.168.1.100"), + ip_address("192.168.1.101"), # Matches config entry + ], + hostname="tube-zigbee-gw.local.", + name="mock_name", + port=6638, + properties={"name": "tube_123456"}, + type="mock_type", + ) + + # Discovery should abort due to single instance check result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) await hass.async_block_till_done() + # Should abort since one of the advertised IPs matches existing socket path assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" @@ -779,12 +971,12 @@ async def test_user_flow(hass: HomeAssistant) -> None: }, ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" with patch("homeassistant.components.zha.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -875,19 +1067,6 @@ async def test_pick_radio_flow(hass: HomeAssistant, radio_type) -> None: assert result["step_id"] == "manual_port_config" -async def test_user_flow_existing_config_entry(hass: HomeAssistant) -> None: - """Test if config entry already exists.""" - MockConfigEntry( - domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER} - ) - - assert result["type"] is FlowResultType.ABORT - - @patch(f"bellows.{PROBE_FUNCTION_PATH}", return_value=False) @patch(f"zigpy_deconz.{PROBE_FUNCTION_PATH}", return_value=False) @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=False) @@ -956,7 +1135,11 @@ async def test_user_port_config_fail(probe_mock, hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, + user_input={ + CONF_DEVICE_PATH: "/dev/ttyUSB33", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "none", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual_port_config" @@ -981,11 +1164,11 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -997,9 +1180,8 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: assert probe_mock.await_count == 1 -@pytest.mark.parametrize("onboarded", [True, False]) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -async def test_hardware(onboarded, hass: HomeAssistant) -> None: +async def test_hardware_not_onboarded(hass: HomeAssistant) -> None: """Test hardware flow.""" data = { "name": "Yellow", @@ -1011,36 +1193,15 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: }, } with patch( - "homeassistant.components.onboarding.async_is_onboarded", return_value=onboarded + "homeassistant.components.onboarding.async_is_onboarded", return_value=False ): - result1 = await hass.config_entries.flow.async_init( + result_create = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data ) + await hass.async_block_till_done() - if onboarded: - # Confirm discovery - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "confirm" - - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={}, - ) - else: - # No need to confirm - result2 = result1 - - assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, - ) - await hass.async_block_till_done() - - assert result3["title"] == "Yellow" - assert result3["data"] == { + assert result_create["title"] == "Yellow" + assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, CONF_FLOW_CONTROL: "hardware", @@ -1050,13 +1211,9 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: } -async def test_hardware_already_setup(hass: HomeAssistant) -> None: - """Test hardware flow -- already setup.""" - - MockConfigEntry( - domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} - ).add_to_hass(hass) - +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_no_flow_strategy(hass: HomeAssistant) -> None: + """Test hardware flow.""" data = { "name": "Yellow", "radio_type": "efr32", @@ -1066,12 +1223,269 @@ async def test_hardware_already_setup(hass: HomeAssistant) -> None: "flow_control": "hardware", }, } - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + # Confirm discovery + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result2["type"] is FlowResultType.MENU + assert result2["step_id"] == "choose_setup_strategy" + + result_create = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, + ) + await hass.async_block_till_done() + + assert result_create["title"] == "Yellow" + assert result_create["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_flow_strategy_advanced(hass: HomeAssistant) -> None: + """Test advanced flow strategy for hardware flow.""" + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "flow_strategy": "advanced", + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result_hardware = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" + + confirm_result = await hass.config_entries.flow.async_configure( + result_hardware["flow_id"], + user_input={}, + ) + + assert confirm_result["type"] is FlowResultType.MENU + assert confirm_result["step_id"] == "choose_formation_strategy" + + result_create = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], + user_input={"next_step_id": "form_new_network"}, + ) + await hass.async_block_till_done() + + assert result_create["type"] is FlowResultType.CREATE_ENTRY + assert result_create["title"] == "Yellow" + assert result_create["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_flow_strategy_recommended(hass: HomeAssistant) -> None: + """Test recommended flow strategy for hardware flow.""" + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "flow_strategy": "recommended", + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result_hardware = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" + + result_create = await hass.config_entries.flow.async_configure( + result_hardware["flow_id"], + user_input={}, + ) + await hass.async_block_till_done() + + assert result_create["type"] is FlowResultType.CREATE_ENTRY + assert result_create["title"] == "Yellow" + assert result_create["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_migration_flow_strategy_advanced( + hass: HomeAssistant, + backup: zigpy.backups.NetworkBackup, + mock_app: AsyncMock, +) -> None: + """Test advanced flow strategy for hardware migration flow.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, + ) + entry.add_to_hass(hass) + + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "flow_strategy": "advanced", + } + with ( + patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup, + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + return_value=True, + ) as mock_async_unload, + ): + result_hardware = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" + + result_confirm = await hass.config_entries.flow.async_configure( + result_hardware["flow_id"], user_input={} + ) + + assert result_confirm["type"] is FlowResultType.MENU + assert result_confirm["step_id"] == "choose_formation_strategy" + + result_formation_strategy = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": "form_new_network"}, + ) + await hass.async_block_till_done() + + assert result_formation_strategy["type"] is FlowResultType.ABORT + assert result_formation_strategy["reason"] == "reconfigure_successful" + assert mock_async_unload.call_count == 0 + assert mock_restore_backup.call_count == 0 + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_migration_flow_strategy_recommended( + hass: HomeAssistant, + backup: zigpy.backups.NetworkBackup, + mock_app: AsyncMock, +) -> None: + """Test recommended flow strategy for hardware migration flow.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, + ) + entry.add_to_hass(hass) + + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "flow_strategy": "recommended", + } + with ( + patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup, + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + return_value=True, + ) as mock_async_unload, + ): + result_hardware = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" + + result_confirm = await hass.config_entries.flow.async_configure( + result_hardware["flow_id"], user_input={} + ) + + assert result_confirm["type"] is FlowResultType.ABORT + assert result_confirm["reason"] == "reconfigure_successful" + assert mock_async_unload.mock_calls == [call(entry.entry_id)] + assert mock_restore_backup.call_count == 1 @pytest.mark.parametrize( @@ -1110,7 +1524,7 @@ def test_prevent_overwrite_ezsp_ieee() -> None: @pytest.fixture -def pick_radio( +def advanced_pick_radio( hass: HomeAssistant, ) -> Generator[RadioPicker]: """Fixture for the first step of the config flow (where a radio is picked).""" @@ -1132,9 +1546,17 @@ def pick_radio( ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" - return result, port + advanced_strategy_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": config_flow.SETUP_STRATEGY_ADVANCED}, + ) + + assert advanced_strategy_result["type"] == FlowResultType.MENU + assert advanced_strategy_result["step_id"] == "choose_formation_strategy" + + return advanced_strategy_result p1 = patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) p2 = patch("homeassistant.components.zha.async_setup_entry") @@ -1144,12 +1566,12 @@ def pick_radio( async def test_strategy_no_network_settings( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test formation strategy when no network settings are present.""" mock_app.load_network_info = MagicMock(side_effect=NetworkNotFormed()) - result, port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) assert ( config_flow.FORMATION_REUSE_SETTINGS not in result["data_schema"].schema["next_step_id"].container @@ -1157,10 +1579,10 @@ async def test_strategy_no_network_settings( async def test_formation_strategy_form_new_network( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test forming a new network.""" - result, port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1175,12 +1597,12 @@ async def test_formation_strategy_form_new_network( async def test_formation_strategy_form_initial_network( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test forming a new network, with no previous settings on the radio.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) - result, port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": config_flow.FORMATION_FORM_INITIAL_NETWORK}, @@ -1231,10 +1653,10 @@ async def test_onboarding_auto_formation_new_hardware( async def test_formation_strategy_reuse_settings( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test reusing existing network settings.""" - result, port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1265,12 +1687,12 @@ def test_parse_uploaded_backup(process_mock) -> None: @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_non_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on non-EZSP coordinators.""" - result, port = await pick_radio(RadioType.znp) + result = await advanced_pick_radio(RadioType.znp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1300,13 +1722,13 @@ async def test_formation_strategy_restore_manual_backup_non_ezsp( @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, backup, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on EZSP coordinators (overwrite IEEE).""" - result, port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1317,39 +1739,53 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp( assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "upload_manual_backup" - with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + None, + ], + ) as mock_restore_backup, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, - ) + # The radio requires user confirmation for restore + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "maybe_confirm_ezsp_restore" - allow_overwrite_ieee_mock.assert_called_once() - mock_app.backups.restore_backup.assert_called_once() + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, + ) assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert mock_restore_backup.call_count == 1 + assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True + @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on EZSP coordinators (don't overwrite IEEE).""" - result, port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1360,37 +1796,48 @@ async def test_formation_strategy_restore_manual_backup_ezsp( assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "upload_manual_backup" - backup = zigpy.backups.NetworkBackup() - - with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + None, + ], + ) as mock_restore_backup, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: False}, - ) + # The radio requires user confirmation for restore + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "maybe_confirm_ezsp_restore" - allow_overwrite_ieee_mock.assert_not_called() - mock_app.backups.restore_backup.assert_called_once_with(backup) + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + # We do not accept + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: False}, + ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert result4["type"] is FlowResultType.ABORT + assert result4["reason"] == "cannot_restore_backup_no_ieee_confirm" + assert mock_restore_backup.call_count == 0 async def test_formation_strategy_restore_manual_backup_invalid_upload( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test restoring a manual backup but an invalid file is uploaded.""" - result, port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1439,7 +1886,10 @@ def test_format_backup_choice() -> None: ) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_formation_strategy_restore_automatic_backup_ezsp( - pick_radio: RadioPicker, mock_app: AsyncMock, make_backup, hass: HomeAssistant + advanced_pick_radio: RadioPicker, + mock_app: AsyncMock, + make_backup, + hass: HomeAssistant, ) -> None: """Test restoring an automatic backup (EZSP radio).""" mock_app.backups.backups = [ @@ -1450,7 +1900,7 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) - result, port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": (config_flow.FORMATION_CHOOSE_AUTOMATIC_BACKUP)}, @@ -1467,18 +1917,10 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( }, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" - - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, - ) - mock_app.backups.restore_backup.assert_called_once() - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_RADIO_TYPE] == "ezsp" @patch( @@ -1489,7 +1931,7 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( @pytest.mark.parametrize("is_advanced", [True, False]) async def test_formation_strategy_restore_automatic_backup_non_ezsp( is_advanced, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, make_backup, hass: HomeAssistant, @@ -1503,7 +1945,7 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp( backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) - result, port = await pick_radio(RadioType.znp) + result = await advanced_pick_radio(RadioType.znp) with patch( "homeassistant.config_entries.ConfigFlow.show_advanced_options", @@ -1543,59 +1985,64 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp( assert result3["data"][CONF_RADIO_TYPE] == "znp" -@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") -async def test_ezsp_restore_without_settings_change_ieee( - allow_overwrite_ieee_mock, - pick_radio: RadioPicker, - mock_app: AsyncMock, - backup, - hass: HomeAssistant, +@patch("homeassistant.components.zha.async_setup_entry", return_value=True) +async def test_options_flow_creates_backup( + async_setup_entry, hass: HomeAssistant, mock_app ) -> None: - """Test a manual backup on EZSP coordinators without settings (no IEEE write).""" - # Fail to load settings - with patch.object( - mock_app, "load_network_info", MagicMock(side_effect=NetworkNotFormed()) - ): - result, port = await pick_radio(RadioType.ezsp) - - # Set the network state, it'll be picked up later after the load "succeeds" - mock_app.state.node_info = backup.node_info - mock_app.state.network_info = copy.deepcopy(backup.network_info) - mock_app.state.network_info.network_key.tx_counter += 10000 - mock_app.state.network_info.metadata["ezsp"] = {} - - # Include the overwrite option, just in case someone uploads a backup with it - backup.network_info.metadata["ezsp"] = {EZSP_OVERWRITE_EUI64: True} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_UPLOAD_MANUAL_BACKUP}, + """Test options flow creates a backup.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, ) - await hass.async_block_till_done() + entry.add_to_hass(hass) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "upload_manual_backup" + zha_gateway = MagicMock() + zha_gateway.application_controller = mock_app + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + "homeassistant.components.zha.config_flow.get_zha_gateway", + return_value=zha_gateway, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, - ) + flow = await hass.config_entries.options.async_init(entry.entry_id) - # We wrote settings when connecting - allow_overwrite_ieee_mock.assert_not_called() - mock_app.backups.restore_backup.assert_called_once_with(backup, create_new=False) + assert flow["step_id"] == "init" - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["data"][CONF_RADIO_TYPE] == "ezsp" + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload", return_value=True + ) as mock_async_unload: + result = await hass.config_entries.options.async_configure( + flow["flow_id"], user_input={} + ) + + mock_app.backups.create_backup.assert_called_once_with(load_devices=True) + mock_async_unload.assert_called_once_with(entry.entry_id) + + assert result["step_id"] == "prompt_migrate_or_reconfigure" @pytest.mark.parametrize( "async_unload_effect", [True, config_entries.OperationNotAllowed()] ) +@pytest.mark.parametrize( + ("input_flow_control", "conf_flow_control"), + [ + ("hardware", "hardware"), + ("software", "software"), + ("none", None), + ], +) @patch( "serial.tools.list_ports.comports", MagicMock( @@ -1608,7 +2055,11 @@ async def test_ezsp_restore_without_settings_change_ieee( ) @patch("homeassistant.components.zha.async_setup_entry", return_value=True) async def test_options_flow_defaults( - async_setup_entry, async_unload_effect, hass: HomeAssistant + async_setup_entry, + async_unload_effect, + input_flow_control, + conf_flow_control, + hass: HomeAssistant, ) -> None: """Test options flow defaults match radio defaults.""" @@ -1651,7 +2102,7 @@ async def test_options_flow_defaults( assert result1["step_id"] == "prompt_migrate_or_reconfigure" result2 = await hass.config_entries.options.async_configure( flow["flow_id"], - user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE}, + user_input={"next_step_id": config_flow.OptionsMigrationIntent.RECONFIGURE}, ) # Current path is the default @@ -1677,9 +2128,20 @@ async def test_options_flow_defaults( # The defaults match our current settings assert result4["step_id"] == "manual_port_config" - assert result4["data_schema"]({}) == entry.data[CONF_DEVICE] + assert entry.data[CONF_DEVICE] == { + "path": "/dev/ttyUSB0", + "baudrate": 12345, + "flow_control": None, + } + assert result4["data_schema"]({}) == { + "path": "/dev/ttyUSB0", + "baudrate": 12345, + "flow_control": "none", + } - with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): + with patch( + f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True) + ) as mock_probe: # Change the serial port path result5 = await hass.config_entries.options.async_configure( flow["flow_id"], @@ -1687,30 +2149,46 @@ async def test_options_flow_defaults( # Change everything CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOW_CONTROL: "software", + CONF_FLOW_CONTROL: input_flow_control, }, ) + # verify we passed the correct flow control to the probe function + assert mock_probe.mock_calls == [ + call( + { + "path": "/dev/new_serial_port", + "baudrate": 54321, + "flow_control": conf_flow_control, + } + ) + ] # The radio has been detected, we can move on to creating the config entry - assert result5["step_id"] == "choose_formation_strategy" + assert result5["step_id"] == "choose_migration_strategy" async_setup_entry.assert_not_called() result6 = await hass.config_entries.options.async_configure( - result1["flow_id"], + result5["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_ADVANCED}, + ) + await hass.async_block_till_done() + + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result6["type"] is FlowResultType.CREATE_ENTRY - assert result6["data"] == {} + assert result7["type"] is FlowResultType.ABORT + assert result7["reason"] == "reconfigure_successful" # The updated entry contains correct settings assert entry.data == { CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOW_CONTROL: "software", + CONF_FLOW_CONTROL: conf_flow_control, }, CONF_RADIO_TYPE: "znp", } @@ -1762,7 +2240,7 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: assert result1["step_id"] == "prompt_migrate_or_reconfigure" result2 = await hass.config_entries.options.async_configure( flow["flow_id"], - user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE}, + user_input={"next_step_id": config_flow.OptionsMigrationIntent.RECONFIGURE}, ) # Radio path must be manually entered @@ -1784,14 +2262,23 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: # The defaults match our current settings assert result4["step_id"] == "manual_port_config" - assert result4["data_schema"]({}) == entry.data[CONF_DEVICE] + assert entry.data[CONF_DEVICE] == { + "path": "socket://localhost:5678", + "baudrate": 12345, + "flow_control": None, + } + assert result4["data_schema"]({}) == { + "path": "socket://localhost:5678", + "baudrate": 12345, + "flow_control": "none", + } with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): result5 = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={} ) - assert result5["step_id"] == "choose_formation_strategy" + assert result5["step_id"] == "choose_migration_strategy" @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) @@ -1833,7 +2320,7 @@ async def test_options_flow_restarts_running_zha_if_cancelled( assert result1["step_id"] == "prompt_migrate_or_reconfigure" result2 = await hass.config_entries.options.async_configure( flow["flow_id"], - user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE}, + user_input={"next_step_id": config_flow.OptionsMigrationIntent.RECONFIGURE}, ) # Radio path must be manually entered @@ -1849,19 +2336,18 @@ async def test_options_flow_restarts_running_zha_if_cancelled( async_setup_entry.assert_called_once_with(hass, entry) -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_options_flow_migration_reset_old_adapter( - hass: HomeAssistant, mock_app + hass: HomeAssistant, backup, mock_app ) -> None: - """Test options flow for migrating from an old radio.""" + """Test options flow for migrating resets the old radio, not the new one.""" entry = MockConfigEntry( version=config_flow.ZhaConfigFlowHandler.VERSION, domain=DOMAIN, data={ CONF_DEVICE: { - CONF_DEVICE_PATH: "/dev/serial/by-id/old_radio", + CONF_DEVICE_PATH: "/dev/ttyUSB_old", CONF_BAUDRATE: 12345, CONF_FLOW_CONTROL: None, }, @@ -1879,39 +2365,158 @@ async def test_options_flow_migration_reset_old_adapter( with patch( "homeassistant.config_entries.ConfigEntries.async_unload", return_value=True ): - result1 = await hass.config_entries.options.async_configure( + result_init = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={} ) entry.mock_state(hass, ConfigEntryState.NOT_LOADED) - assert result1["step_id"] == "prompt_migrate_or_reconfigure" - result2 = await hass.config_entries.options.async_configure( - flow["flow_id"], - user_input={"next_step_id": config_flow.OPTIONS_INTENT_MIGRATE}, + assert result_init["step_id"] == "prompt_migrate_or_reconfigure" + + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + return_value=ProbeResult.RADIO_TYPE_DETECTED, + ), + patch( + "serial.tools.list_ports.comports", + MagicMock(return_value=[com_port("/dev/ttyUSB_new")]), + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + ): + result_migrate = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={"next_step_id": config_flow.OptionsMigrationIntent.MIGRATE}, + ) + + # Now we choose the new radio + assert result_migrate["step_id"] == "choose_serial_port" + + result_port = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={ + CONF_DEVICE_PATH: "/dev/ttyUSB_new - Some serial port, s/n: 1234 - Virtual serial port" + }, + ) + + assert result_port["step_id"] == "choose_migration_strategy" + + # A temporary radio manager is created to reset the old adapter + mock_radio_manager = AsyncMock() + + with patch( + "homeassistant.components.zha.config_flow.ZhaRadioManager", + spec=ZhaRadioManager, + side_effect=[mock_radio_manager], + ): + result_strategy = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={ + "next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED, + }, + ) + + # The old adapter is reset, not the new one + assert mock_radio_manager.device_path == "/dev/ttyUSB_old" + assert mock_radio_manager.async_reset_adapter.call_count == 1 + + assert result_strategy["type"] is FlowResultType.ABORT + assert result_strategy["reason"] == "reconfigure_successful" + + # The entry is updated + assert entry.data["device"]["path"] == "/dev/ttyUSB_new" + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_options_flow_reconfigure_no_reset( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test options flow for reconfiguring does not require the old adapter.""" + + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB_old", + CONF_BAUDRATE: 12345, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, ) + entry.add_to_hass(hass) - # User must explicitly approve radio reset - assert result2["step_id"] == "intent_migrate" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - mock_app.reset_network_info = AsyncMock() + flow = await hass.config_entries.options.async_init(entry.entry_id) - result3 = await hass.config_entries.options.async_configure( - flow["flow_id"], - user_input={}, - ) + # ZHA gets unloaded + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload", return_value=True + ): + result_init = await hass.config_entries.options.async_configure( + flow["flow_id"], user_input={} + ) - mock_app.reset_network_info.assert_awaited_once() + entry.mock_state(hass, ConfigEntryState.NOT_LOADED) - # Now we can unplug the old radio - assert result3["step_id"] == "instruct_unplug" + assert result_init["step_id"] == "prompt_migrate_or_reconfigure" - # And move on to choosing the new radio - result4 = await hass.config_entries.options.async_configure( - flow["flow_id"], - user_input={}, - ) - assert result4["step_id"] == "choose_serial_port" + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + return_value=ProbeResult.RADIO_TYPE_DETECTED, + ), + patch( + "serial.tools.list_ports.comports", + MagicMock(return_value=[com_port("/dev/ttyUSB_new")]), + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + ): + result_reconfigure = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={"next_step_id": config_flow.OptionsMigrationIntent.RECONFIGURE}, + ) + + # Now we choose the new radio + assert result_reconfigure["step_id"] == "choose_serial_port" + + result_port = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={ + CONF_DEVICE_PATH: "/dev/ttyUSB_new - Some serial port, s/n: 1234 - Virtual serial port" + }, + ) + + assert result_port["step_id"] == "choose_migration_strategy" + + with patch( + "homeassistant.components.zha.config_flow.ZhaRadioManager" + ) as mock_radio_manager: + result_strategy = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={ + "next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED, + }, + ) + + # A temp radio manager is never created + assert mock_radio_manager.call_count == 0 + + assert result_strategy["type"] is FlowResultType.ABORT + assert result_strategy["reason"] == "reconfigure_successful" + + # The entry is updated + assert entry.data["device"]["path"] == "/dev/ttyUSB_new" @pytest.mark.parametrize( @@ -2061,3 +2666,217 @@ async def test_migration_ti_cc_to_znp( assert config_entry.version > 2 assert config_entry.data[CONF_RADIO_TYPE] == new_type + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_resets_old_radio( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test that the old radio is reset during migration.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "ezsp", + }, + ) + entry.add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + mock_temp_radio_mgr = AsyncMock() + mock_temp_radio_mgr.async_reset_adapter = AsyncMock() + + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + patch( + "homeassistant.components.zha.config_flow.ZhaRadioManager", + side_effect=[ZhaRadioManager(), mock_temp_radio_mgr], + ), + ): + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "reconfigure_successful" + + # We reset the old radio + assert mock_temp_radio_mgr.async_reset_adapter.call_count == 1 + + # It should be configured with the old radio's settings + assert mock_temp_radio_mgr.radio_type == RadioType.ezsp + assert mock_temp_radio_mgr.device_path == "/dev/ttyUSB0" + assert mock_temp_radio_mgr.device_settings == { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +@patch(f"bellows.{PROBE_FUNCTION_PATH}", return_value=True) +async def test_config_flow_serial_resolution_oserror( + probe_mock, hass: HomeAssistant +) -> None: + """Test that OSError during serial port resolution is handled.""" + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + with ( + patch( + "homeassistant.components.zha.config_flow.usb.get_serial_by_id", + side_effect=OSError("Test error"), + ), + ): + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + assert result_init["type"] is FlowResultType.ABORT + assert result_init["reason"] == "cannot_resolve_path" + assert result_init["description_placeholders"] == {"path": "/dev/ttyZIGBEE"} + + +@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") +async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_write_fail( + allow_overwrite_ieee_mock, + advanced_pick_radio: RadioPicker, + mock_app: AsyncMock, + backup, + hass: HomeAssistant, +) -> None: + """Test restoring a manual backup on EZSP coordinators (overwrite IEEE) with a write failure.""" + advanced_strategy_result = await advanced_pick_radio(RadioType.ezsp) + + upload_backup_result = await hass.config_entries.flow.async_configure( + advanced_strategy_result["flow_id"], + user_input={"next_step_id": config_flow.FORMATION_UPLOAD_MANUAL_BACKUP}, + ) + await hass.async_block_till_done() + + assert upload_backup_result["type"] is FlowResultType.FORM + assert upload_backup_result["step_id"] == "upload_manual_backup" + + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + CannotWriteNetworkSettings("Failed to write settings"), + ], + ) as mock_restore_backup, + ): + confirm_restore_result = await hass.config_entries.flow.async_configure( + upload_backup_result["flow_id"], + user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, + ) + + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() + + # The radio requires user confirmation for restore + assert confirm_restore_result["type"] is FlowResultType.FORM + assert confirm_restore_result["step_id"] == "maybe_confirm_ezsp_restore" + + final_result = await hass.config_entries.flow.async_configure( + confirm_restore_result["flow_id"], + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, + ) + + assert final_result["type"] is FlowResultType.ABORT + assert final_result["reason"] == "cannot_restore_backup" + assert ( + "Failed to write settings" in final_result["description_placeholders"]["error"] + ) + + assert mock_restore_backup.call_count == 1 + assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True + + +@patch(f"bellows.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +async def test_migrate_setup_options_with_ignored_discovery( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that ignored discovery info is migrated to options.""" + + # Ignored ZHA + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AAAA:AAAA_1234_test_zigbee radio", + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB1", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } + }, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + # Set up one discovery entry + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="BBBB", + vid="BBBB", + serial_number="5678", + description="zigbee radio", + manufacturer="test manufacturer", + ) + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + # Progress the discovery + confirm_result = await hass.config_entries.flow.async_configure( + discovery_result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + # We only show "setup" options, not "migrate" + assert confirm_result["step_id"] == "choose_setup_strategy" + assert confirm_result["menu_options"] == [ + "setup_strategy_recommended", + "setup_strategy_advanced", + ] diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 70fdac2c313..133a7fe612b 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -1,8 +1,10 @@ """Test ZHA cover.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import closures import zigpy.zcl.foundation as zcl_f @@ -60,7 +62,11 @@ WCT = closures.WindowCovering.WindowCoveringType WCCS = closures.WindowCovering.ConfigStatus -async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: +async def test_cover( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: """Test ZHA cover platform.""" await setup_zha() @@ -327,7 +333,9 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: async def test_cover_failures( - hass: HomeAssistant, setup_zha, zigpy_device_mock + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA cover platform failure cases.""" await setup_zha() diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index becf9d81557..0ebee66fb9a 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -1,9 +1,11 @@ """The test for ZHA device automation actions.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest from pytest_unordered import unordered +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general, security import zigpy.zcl.foundation as zcl_f @@ -56,8 +58,8 @@ async def test_get_actions( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test we get the expected actions from a ZHA device.""" @@ -142,8 +144,8 @@ async def test_get_actions( async def test_action( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test for executing a ZHA device action.""" await setup_zha() @@ -221,7 +223,9 @@ async def test_action( async def test_invalid_zha_event_type( - hass: HomeAssistant, setup_zha, zigpy_device_mock + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test that unexpected types are not passed to `zha_send_event`.""" await setup_zha() @@ -261,7 +265,9 @@ async def test_invalid_zha_event_type( async def test_client_unique_id_suffix_stripped( - hass: HomeAssistant, setup_zha, zigpy_device_mock + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test that the `_CLIENT_` unique ID suffix is stripped.""" assert await async_setup_component( diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index 8a587966f81..bb94bebc35b 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -1,11 +1,13 @@ """Test ZHA Device Tracker.""" +from collections.abc import Callable, Coroutine from datetime import timedelta import time from unittest.mock import patch import pytest from zha.application.registries import SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general @@ -44,7 +46,9 @@ def device_tracker_platforms_only(): async def test_device_tracker( - hass: HomeAssistant, setup_zha, zigpy_device_mock + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA device tracker platform.""" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index ace3029dac9..d0b6eec62bf 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -1,5 +1,6 @@ """ZHA device automation trigger tests.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest @@ -59,7 +60,7 @@ def _same_lists(list_a, list_b): async def test_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - setup_zha, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test ZHA device triggers.""" @@ -145,7 +146,9 @@ async def test_triggers( async def test_no_triggers( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, setup_zha + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test ZHA device with no triggers.""" await setup_zha() @@ -185,7 +188,7 @@ async def test_if_fires_on_event( hass: HomeAssistant, device_registry: dr.DeviceRegistry, service_calls: list[ServiceCall], - setup_zha, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test for remote triggers firing.""" @@ -261,7 +264,7 @@ async def test_device_offline_fires( hass: HomeAssistant, device_registry: dr.DeviceRegistry, service_calls: list[ServiceCall], - setup_zha, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test for device offline triggers firing.""" @@ -317,7 +320,7 @@ async def test_exception_no_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, - setup_zha, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test for exception when validating device triggers.""" @@ -370,7 +373,7 @@ async def test_exception_bad_trigger( hass: HomeAssistant, device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, - setup_zha, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test for exception when validating device triggers.""" @@ -431,7 +434,7 @@ async def test_validate_trigger_config_missing_info( device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, - setup_zha, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test device triggers referring to a missing device.""" @@ -499,7 +502,7 @@ async def test_validate_trigger_config_unloaded_bad_info( config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, zigpy_app_controller: ControllerApplication, - setup_zha, + setup_zha: Callable[..., Coroutine[None]], ) -> None: """Test device triggers referring to a missing device.""" diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index d32dd191527..bc438dff285 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -1,10 +1,12 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props +from zigpy.device import Device from zigpy.profiles import zha from zigpy.types import EUI64, NWK from zigpy.zcl.clusters import security @@ -42,8 +44,8 @@ async def test_diagnostics_for_config_entry( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for config entry.""" @@ -90,8 +92,8 @@ async def test_diagnostics_for_device( hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for device.""" diff --git a/tests/components/zha/test_entity.py b/tests/components/zha/test_entity.py index add98bb96bf..eac987a070e 100644 --- a/tests/components/zha/test_entity.py +++ b/tests/components/zha/test_entity.py @@ -1,5 +1,8 @@ """Test ZHA entities.""" +from collections.abc import Callable, Coroutine + +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general @@ -12,8 +15,8 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE async def test_device_registry_via_device( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], device_registry: dr.DeviceRegistry, ) -> None: """Test ZHA `via_device` is set correctly.""" diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 0105c569653..1bd5325dc92 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -1,9 +1,11 @@ """Test ZHA fan.""" +from collections.abc import Callable, Coroutine from unittest.mock import call, patch import pytest from zha.application.platforms.fan.const import PRESET_MODE_ON +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general, hvac @@ -58,7 +60,11 @@ def fan_platform_only(): yield -async def test_fan(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: +async def test_fan( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: """Test ZHA fan platform.""" await setup_zha() diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 887284919da..8020e2d365f 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,6 +1,7 @@ """Tests for ZHA integration init.""" import asyncio +from collections.abc import Callable import typing from unittest.mock import AsyncMock, Mock, patch import zoneinfo @@ -8,6 +9,7 @@ import zoneinfo import pytest from zigpy.application import ControllerApplication from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.device import Device from zigpy.exceptions import TransientConnectionError from homeassistant.components.zha.const import ( @@ -193,9 +195,9 @@ async def test_setup_with_v3_cleaning_uri( async def test_migration_baudrate_and_flow_control( radio_type: str, old_baudrate: int, - old_flow_control: typing.Literal["hardware", "software", None], + old_flow_control: typing.Literal["hardware", "software"] | None, new_baudrate: int, - new_flow_control: typing.Literal["hardware", "software", None], + new_flow_control: typing.Literal["hardware", "software"] | None, hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: @@ -231,7 +233,7 @@ async def test_migration_baudrate_and_flow_control( async def test_zha_retry_unique_ids( hass: HomeAssistant, config_entry: MockConfigEntry, - zigpy_device_mock, + zigpy_device_mock: Callable[..., Device], mock_zigpy_connect: ControllerApplication, caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index ef2714b3b58..59bd5d4cdd5 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -1,9 +1,11 @@ """Test ZHA light.""" +from collections.abc import Callable, Coroutine from unittest.mock import AsyncMock, call, patch, sentinel import pytest from zha.application.platforms.light.const import FLASH_EFFECTS +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl import Cluster from zigpy.zcl.clusters import general, lighting @@ -114,8 +116,8 @@ def light_platform_only(): ) async def test_light( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], device, reporting, ) -> None: @@ -193,7 +195,9 @@ async def test_light( new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), ) async def test_on_with_off_color( - hass: HomeAssistant, setup_zha, zigpy_device_mock + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test turning on the light and sending color commands before on/level commands for supporting lights.""" @@ -576,8 +580,8 @@ async def async_test_flash_from_hass( ) async def test_light_exception_on_creation( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], caplog: pytest.LogCaptureFixture, ) -> None: """Test ZHA light entity creation exception.""" diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index dd4afb0ae14..1fa893d34d4 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -1,8 +1,10 @@ """Test ZHA lock.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl import Cluster from zigpy.zcl.clusters import closures, general @@ -36,7 +38,11 @@ def lock_platform_only(): yield -async def test_lock(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: +async def test_lock( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: """Test ZHA lock platform.""" await setup_zha() diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py index 0b27cd095a9..1eb7ce0e01d 100644 --- a/tests/components/zha/test_logbook.py +++ b/tests/components/zha/test_logbook.py @@ -1,9 +1,11 @@ """ZHA logbook describe events tests.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest from zha.application.const import ZHA_EVENT +from zigpy.device import Device import zigpy.profiles.zha from zigpy.zcl.clusters import general @@ -46,7 +48,11 @@ def sensor_platform_only(): @pytest.fixture -async def mock_devices(hass: HomeAssistant, setup_zha, zigpy_device_mock): +async def mock_devices( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +): """IAS device fixture.""" await setup_zha() @@ -165,7 +171,7 @@ async def test_zha_logbook_event_device_no_triggers( ) -> None: """Test ZHA logbook events with device and without triggers.""" - zigpy_device, zha_device = mock_devices + _zigpy_device, zha_device = mock_devices ieee_address = str(zha_device.device.ieee) reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 91f5e32942f..511394161e3 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -1,8 +1,10 @@ """Test ZHA analog output.""" +from collections.abc import Callable, Coroutine from unittest.mock import call, patch import pytest +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general import zigpy.zcl.foundation as zcl_f @@ -39,7 +41,11 @@ def number_platform_only(): yield -async def test_number(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: +async def test_number( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: """Test ZHA number platform.""" await setup_zha() diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 59494dd0d09..c8086cc49d9 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -16,6 +16,7 @@ from homeassistant.components.zha.const import DOMAIN from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry @@ -88,7 +89,7 @@ def com_port(device="/dev/ttyUSB1234"): @pytest.fixture -def mock_connect_zigpy_app() -> Generator[MagicMock]: +def mock_create_zigpy_app() -> Generator[MagicMock]: """Mock the radio connection.""" mock_connect_app = MagicMock() @@ -98,7 +99,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock]: ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ): yield mock_connect_app @@ -107,7 +108,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock]: @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migrate_matching_port( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -167,7 +168,7 @@ async def test_migrate_matching_port( @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migrate_matching_port_usb( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -214,7 +215,7 @@ async def test_migrate_matching_port_usb( async def test_migrate_matching_port_config_entry_not_loaded( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -268,13 +269,13 @@ async def test_migrate_matching_port_config_entry_not_loaded( @patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.async_restore_backup_step_1", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", side_effect=OSError, ) async def test_migrate_matching_port_retry( mock_restore_backup_step_1, hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -331,7 +332,7 @@ async def test_migrate_matching_port_retry( async def test_migrate_non_matching_port( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -379,7 +380,7 @@ async def test_migrate_non_matching_port( async def test_migrate_initiate_failure( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test retries with failure.""" # Set up the config entry @@ -416,7 +417,7 @@ async def test_migrate_initiate_failure( } mock_load_info = AsyncMock(side_effect=OSError()) - mock_connect_zigpy_app.__aenter__.return_value.load_network_info = mock_load_info + mock_create_zigpy_app.__aenter__.return_value.load_network_info = mock_load_info migration_helper = radio_manager.ZhaMultiPANMigrationHelper(hass, config_entry) @@ -484,3 +485,32 @@ async def test_detect_radio_type_failure_no_detect( ): assert await radio_manager.detect_radio_type() == ProbeResult.PROBING_FAILED assert radio_manager.radio_type is None + + +async def test_load_network_settings_oserror( + radio_manager: ZhaRadioManager, hass: HomeAssistant +) -> None: + """Test that OSError during network settings loading is handled.""" + radio_manager.device_path = "/dev/ttyZigbee" + radio_manager.radio_type = RadioType.ezsp + radio_manager.device_settings = {"database": "/test/db/path"} + + with ( + patch("os.path.exists", side_effect=OSError("Test error")), + pytest.raises(HomeAssistantError, match="Could not read the ZHA database"), + ): + await radio_manager.async_load_network_settings() + + +async def test_create_zigpy_app_connect_oserror( + radio_manager: ZhaRadioManager, hass: HomeAssistant, mock_app +) -> None: + """Test that OSError during zigpy app connection is handled.""" + radio_manager.radio_type = RadioType.ezsp + radio_manager.device_settings = {CONF_DEVICE_PATH: "/dev/ttyZigbee"} + + mock_app.connect.side_effect = OSError("Test error") + + with pytest.raises(HomeAssistantError, match="Failed to connect to Zigbee adapter"): + async with radio_manager.create_zigpy_app(): + pytest.fail("Should not be reached") diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index f0f742503e3..fec02a68afe 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -1,9 +1,11 @@ """Test ZHA select entities.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general, security @@ -49,8 +51,8 @@ def select_select_only(): async def test_select( hass: HomeAssistant, entity_registry: er.EntityRegistry, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA select platform.""" @@ -127,8 +129,8 @@ async def test_select( async def test_select_restore_state( hass: HomeAssistant, entity_registry: er.EntityRegistry, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], restored_state: str, expected_state: str, ) -> None: diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 2e6b9e8bd6a..e7c0410e958 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,8 +1,10 @@ """Test ZHA sensor.""" +from collections.abc import Callable, Coroutine from unittest.mock import patch import pytest +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl import Cluster from zigpy.zcl.clusters import general, homeautomation, hvac, measurement, smartenergy @@ -519,8 +521,8 @@ async def async_test_pi_heating_demand( ) async def test_sensor( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], cluster_id, entity_suffix, test_func, diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py index a5f2db22ce5..83abb0f8b92 100644 --- a/tests/components/zha/test_silabs_multiprotocol.py +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING from unittest.mock import call, patch @@ -25,7 +26,9 @@ def required_platform_only(): yield -async def test_async_get_channel_active(hass: HomeAssistant, setup_zha) -> None: +async def test_async_get_channel_active( + hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]] +) -> None: """Test reading channel with an active ZHA installation.""" await setup_zha() @@ -33,7 +36,9 @@ async def test_async_get_channel_active(hass: HomeAssistant, setup_zha) -> None: async def test_async_get_channel_missing( - hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_app_controller: ControllerApplication, ) -> None: """Test reading channel with an inactive ZHA installation, no valid channel.""" await setup_zha() @@ -52,7 +57,9 @@ async def test_async_get_channel_no_zha(hass: HomeAssistant) -> None: assert await silabs_multiprotocol.async_get_channel(hass) is None -async def test_async_using_multipan_active(hass: HomeAssistant, setup_zha) -> None: +async def test_async_using_multipan_active( + hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]] +) -> None: """Test async_using_multipan with an active ZHA installation.""" await setup_zha() @@ -65,7 +72,9 @@ async def test_async_using_multipan_no_zha(hass: HomeAssistant) -> None: async def test_change_channel( - hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_app_controller: ControllerApplication, ) -> None: """Test changing the channel.""" await setup_zha() @@ -89,7 +98,7 @@ async def test_change_channel_no_zha( @pytest.mark.parametrize(("delay", "sleep"), [(0, 0), (5, 0), (15, 15 - 10.27)]) async def test_change_channel_delay( hass: HomeAssistant, - setup_zha, + setup_zha: Callable[..., Coroutine[None]], zigpy_app_controller: ControllerApplication, delay: float, sleep: float, diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index 5849cc6f233..564fd4db0fb 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -1,5 +1,6 @@ """Test zha siren.""" +from collections.abc import Callable, Coroutine from datetime import timedelta from unittest.mock import ANY, call, patch @@ -9,6 +10,7 @@ from zha.application.const import ( WARNING_DEVICE_SOUND_MEDIUM, ) from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from zigpy.device import Device from zigpy.profiles import zha import zigpy.zcl from zigpy.zcl.clusters import general, security @@ -51,7 +53,11 @@ def siren_platform_only(): yield -async def test_siren(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: +async def test_siren( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: """Test zha siren platform.""" await setup_zha() diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index cc4e41485f9..ca9e1345d3d 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -1,8 +1,10 @@ """Test ZHA switch.""" +from collections.abc import Callable, Coroutine from unittest.mock import call, patch import pytest +from zigpy.device import Device from zigpy.profiles import zha from zigpy.zcl.clusters import general import zigpy.zcl.foundation as zcl_f @@ -40,7 +42,11 @@ def switch_platform_only(): yield -async def test_switch(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: +async def test_switch( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: """Test ZHA switch platform.""" await setup_zha() diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index 04d190b170c..3d4ea96373c 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -1,11 +1,13 @@ """Test ZHA firmware updates.""" +from collections.abc import Callable, Coroutine from unittest.mock import AsyncMock, PropertyMock, call, patch import pytest from zha.application.platforms.update import ( FirmwareUpdateEntity as ZhaFirmwareUpdateEntity, ) +from zigpy.device import Device from zigpy.exceptions import DeliveryError from zigpy.ota import OtaImagesResult, OtaImageWithMetadata import zigpy.ota.image as firmware @@ -73,7 +75,7 @@ def update_platform_only(): async def setup_test_data( hass: HomeAssistant, - zigpy_device_mock, + zigpy_device_mock: Callable[..., Device], skip_attribute_plugs=False, file_not_found=False, ): @@ -163,8 +165,8 @@ async def setup_test_data( async def test_firmware_update_notification_from_zigpy( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA update platform - firmware update notification.""" await setup_zha() @@ -206,8 +208,8 @@ async def test_firmware_update_notification_from_zigpy( async def test_firmware_update_notification_from_service_call( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA update platform - firmware update manual check.""" await setup_zha() @@ -294,8 +296,8 @@ def make_packet(zigpy_device, cluster, cmd_name: str, **kwargs): @patch("zigpy.device.AFTER_OTA_ATTR_READ_DELAY", 0.01) async def test_firmware_update_success( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA update platform - firmware update success.""" await setup_zha() @@ -336,7 +338,7 @@ async def test_firmware_update_success( async def endpoint_reply(cluster, sequence, data, **kwargs): if cluster == general.Ota.cluster_id: - hdr, cmd = ota_cluster.deserialize(data) + _hdr, cmd = ota_cluster.deserialize(data) if isinstance(cmd, general.Ota.ImageNotifyCommand): zha_device.device.device.packet_received( make_packet( @@ -491,8 +493,8 @@ async def test_firmware_update_success( async def test_firmware_update_raises( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA update platform - firmware update raises.""" await setup_zha() @@ -532,7 +534,7 @@ async def test_firmware_update_raises( async def endpoint_reply(cluster, sequence, data, **kwargs): if cluster == general.Ota.cluster_id: - hdr, cmd = ota_cluster.deserialize(data) + _hdr, cmd = ota_cluster.deserialize(data) if isinstance(cmd, general.Ota.ImageNotifyCommand): zha_device.device.device.packet_received( make_packet( @@ -587,8 +589,8 @@ async def test_firmware_update_raises( async def test_update_release_notes( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> None: """Test ZHA update platform release notes.""" await setup_zha() @@ -647,8 +649,8 @@ async def test_update_release_notes( async def test_update_version_sync_device_registry( hass: HomeAssistant, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], device_registry: dr.DeviceRegistry, ) -> None: """Test firmware version syncing between the ZHA device and Home Assistant.""" diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index ae1ea90d1f9..df945b8afc2 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -3,6 +3,7 @@ from __future__ import annotations from binascii import unhexlify +from collections.abc import Callable, Coroutine from copy import deepcopy from typing import TYPE_CHECKING from unittest.mock import ANY, AsyncMock, MagicMock, call, patch @@ -23,7 +24,7 @@ from zha.application.const import ( CLUSTER_TYPE_IN, ) from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent -from zha.zigbee.device import ClusterHandlerConfigurationComplete +from zha.zigbee.device import ClusterHandlerConfigurationComplete, Device import zigpy.backups from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE import zigpy.profiles.zha @@ -97,8 +98,8 @@ def required_platform_only(): async def zha_client( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - setup_zha, - zigpy_device_mock, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], ) -> MockHAClientWebSocket: """Get ZHA WebSocket client.""" @@ -261,7 +262,9 @@ async def test_get_zha_config(zha_client) -> None: async def test_get_zha_config_with_alarm( - hass: HomeAssistant, zha_client, zigpy_device_mock + hass: HomeAssistant, + zha_client, + zigpy_device_mock: Callable[..., Device], ) -> None: """Test getting ZHA custom configuration.""" @@ -596,7 +599,9 @@ async def test_remove_group_member(hass: HomeAssistant, zha_client) -> None: @pytest.fixture async def app_controller( - hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_app_controller: ControllerApplication, ) -> ControllerApplication: """Fixture for zigpy Application Controller.""" await setup_zha() @@ -1138,7 +1143,9 @@ async def test_websocket_bind_unbind_group( async def test_websocket_reconfigure( - hass: HomeAssistant, zha_client: MockHAClientWebSocket, zigpy_device_mock + hass: HomeAssistant, + zha_client: MockHAClientWebSocket, + zigpy_device_mock: Callable[..., Device], ) -> None: """Test websocket API to reconfigure a device.""" gateway = get_zha_gateway(hass) diff --git a/tests/components/zimi/common.py b/tests/components/zimi/common.py index 13582b3d42c..50ffc0ac587 100644 --- a/tests/components/zimi/common.py +++ b/tests/components/zimi/common.py @@ -34,12 +34,13 @@ INPUT_PORT = 5003 def mock_api_device( device_name: str | None = None, entity_type: str | None = None, + entity_id: str | None = None, ) -> MagicMock: """Mock a Zimi ControlPointDevice which is used in the zcc API with defaults.""" mock_api_device = create_autospec(ControlPointDevice) - mock_api_device.identifier = ENTITY_INFO["id"] + mock_api_device.identifier = entity_id or ENTITY_INFO["id"] mock_api_device.room = ENTITY_INFO["room"] mock_api_device.name = ENTITY_INFO["name"] mock_api_device.type = entity_type or ENTITY_INFO["type"] diff --git a/tests/components/zimi/test_cover.py b/tests/components/zimi/test_cover.py index 68809af49e6..a12b59ada13 100644 --- a/tests/components/zimi/test_cover.py +++ b/tests/components/zimi/test_cover.py @@ -14,7 +14,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import ENTITY_INFO, mock_api_device, setup_platform +from .common import mock_api_device, setup_platform async def test_cover_entity( @@ -25,26 +25,54 @@ async def test_cover_entity( ) -> None: """Tests cover entity.""" - device_name = "Cover Controller" - entity_key = "cover.cover_controller_test_entity_name" + blind_device_name = "Blind Controller" + blind_entity_key = "cover.blind_controller_test_entity_name" + blind_entity_id = "test-entity-id-blind" + door_device_name = "Cover Controller" + door_entity_key = "cover.cover_controller_test_entity_name" + door_entity_id = "test-entity-id-door" entity_type = Platform.COVER - mock_api.doors = [mock_api_device(device_name=device_name, entity_type=entity_type)] + mock_api.blinds = [ + mock_api_device( + device_name=blind_device_name, + entity_type=entity_type, + entity_id=blind_entity_id, + ) + ] + mock_api.doors = [ + mock_api_device( + device_name=door_device_name, + entity_type=entity_type, + entity_id=door_entity_id, + ) + ] await setup_platform(hass, entity_type) - entity = entity_registry.entities[entity_key] - assert entity.unique_id == ENTITY_INFO["id"] + blind_entity = entity_registry.entities[blind_entity_key] + assert blind_entity.unique_id == blind_entity_id assert ( - entity.supported_features + blind_entity.supported_features == CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP | CoverEntityFeature.SET_POSITION ) - state = hass.states.get(entity_key) + door_entity = entity_registry.entities[door_entity_key] + assert door_entity.unique_id == door_entity_id + + assert ( + door_entity.supported_features + == CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + state = hass.states.get(door_entity_key) assert state == snapshot services = hass.services.async_services() @@ -53,7 +81,7 @@ async def test_cover_entity( await hass.services.async_call( entity_type, SERVICE_CLOSE_COVER, - {"entity_id": entity_key}, + {"entity_id": door_entity_key}, blocking=True, ) assert mock_api.doors[0].close_door.called @@ -62,7 +90,7 @@ async def test_cover_entity( await hass.services.async_call( entity_type, SERVICE_OPEN_COVER, - {"entity_id": entity_key}, + {"entity_id": door_entity_key}, blocking=True, ) assert mock_api.doors[0].open_door.called @@ -71,7 +99,7 @@ async def test_cover_entity( await hass.services.async_call( entity_type, SERVICE_SET_COVER_POSITION, - {"entity_id": entity_key, "position": 50}, + {"entity_id": door_entity_key, "position": 50}, blocking=True, ) assert mock_api.doors[0].open_to_percentage.called diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index ab78fc90bae..dae76186702 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -12,8 +12,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: """Test that zone raises ConditionError on errors.""" config = { "condition": "zone", - "entity_id": "device_tracker.cat", - "zone": "zone.home", + "options": {"entity_id": "device_tracker.cat", "zone": "zone.home"}, } config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) @@ -66,8 +65,10 @@ async def test_zone_raises(hass: HomeAssistant) -> None: config = { "condition": "zone", - "entity_id": ["device_tracker.cat", "device_tracker.dog"], - "zone": ["zone.home", "zone.work"], + "options": { + "entity_id": ["device_tracker.cat", "device_tracker.dog"], + "zone": ["zone.home", "zone.work"], + }, } config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) @@ -102,8 +103,10 @@ async def test_zone_multiple_entities(hass: HomeAssistant) -> None: { "alias": "Zone Condition", "condition": "zone", - "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], - "zone": "zone.home", + "options": { + "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], + "zone": "zone.home", + }, }, ], } @@ -161,8 +164,10 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: "conditions": [ { "condition": "zone", - "entity_id": "device_tracker.person", - "zone": ["zone.home", "zone.work"], + "options": { + "entity_id": "device_tracker.person", + "zone": ["zone.home", "zone.work"], + }, }, ], } diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index f60c0169055..1a765288cc1 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Generator import copy import io +import logging from typing import Any, cast from unittest.mock import DEFAULT, AsyncMock, MagicMock, patch @@ -925,6 +926,7 @@ async def integration_fixture( hass: HomeAssistant, client: MagicMock, platforms: list[Platform], + caplog: pytest.LogCaptureFixture, ) -> MockConfigEntry: """Set up the zwave_js integration.""" entry = MockConfigEntry( @@ -939,6 +941,11 @@ async def integration_fixture( client.async_send_command.reset_mock() + # Make sure no errors logged during setup. + # Eg. unique id collisions are only logged as errors and not raised, + # and may not cause tests to fail otherwise. + assert not any(record.levelno == logging.ERROR for record in caplog.records) + return entry diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index bab13666a29..9b006e008af 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -29,6 +29,8 @@ from homeassistant.components.zwave_js.const import ( CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, + CONF_ADDON_SOCKET, + CONF_SOCKET_PATH, CONF_USB_PATH, DOMAIN, ) @@ -36,6 +38,7 @@ from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +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 @@ -48,6 +51,19 @@ ADDON_DISCOVERY_INFO = { "port": 3001, } +ESPHOME_DISCOVERY_INFO = ESPHomeServiceInfo( + name="mock-name", + zwave_home_id=1234, + ip_address="192.168.1.100", + port=6053, +) + +ESPHOME_DISCOVERY_INFO_CLEAN = ESPHomeServiceInfo( + name="mock-name", + zwave_home_id=None, + ip_address="192.168.1.100", + port=6053, +) USB_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zwave", @@ -239,6 +255,7 @@ async def test_manual(hass: HomeAssistant) -> None: assert result2["data"] == { "url": "ws://localhost:3000", "usb_path": None, + "socket_path": None, "s0_legacy_key": None, "s2_access_control_key": None, "s2_authenticated_key": None, @@ -433,6 +450,7 @@ async def test_supervisor_discovery( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -539,6 +557,7 @@ async def test_clean_discovery_on_user_create( assert result["data"] == { "url": "ws://localhost:3000", "usb_path": None, + "socket_path": None, "s0_legacy_key": None, "s2_access_control_key": None, "s2_authenticated_key": None, @@ -754,6 +773,7 @@ async def test_usb_discovery( assert result["data"] == { "url": "ws://host1:3001", "usb_path": device, + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -866,6 +886,7 @@ async def test_usb_discovery_addon_not_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": USB_DISCOVERY_INFO.device, + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -976,7 +997,12 @@ async def test_usb_discovery_migration( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device}) + "core_zwave_js", + AddonsOptions( + config={ + CONF_ADDON_DEVICE: USB_DISCOVERY_INFO.device, + } + ), ) await hass.async_block_till_done() @@ -1006,6 +1032,7 @@ async def test_usb_discovery_migration( assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device + assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True assert "keep_old_devices" not in entry.data assert entry.unique_id == "3245146787" @@ -1104,7 +1131,12 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device}) + "core_zwave_js", + AddonsOptions( + config={ + "device": USB_DISCOVERY_INFO.device, + } + ), ) await hass.async_block_till_done() @@ -1135,11 +1167,375 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device + assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True assert entry.unique_id == "1234" assert "keep_old_devices" in entry.data +@pytest.mark.parametrize( + "service_info", [ESPHOME_DISCOVERY_INFO, ESPHOME_DISCOVERY_INFO_CLEAN] +) +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") +async def test_esphome_discovery_intent_custom( + hass: HomeAssistant, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, + service_info: ESPHomeServiceInfo, +) -> None: + """Test ESPHome discovery success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=service_info, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + assert result["menu_options"] == ["intent_recommended", "intent_custom"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + + assert result["step_id"] == "install_addon" + assert result["type"] is FlowResultType.SHOW_PROGRESS + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert install_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, + ) + + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + with ( + patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["result"].unique_id == "1234" + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": None, + "socket_path": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + "use_addon": True, + "integration_created_addon": True, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_running", "addon_info") +async def test_esphome_discovery_intent_recommended( + hass: HomeAssistant, + set_addon_options: AsyncMock, + addon_options: dict, +) -> None: + """Test ESPHome discovery success path.""" + addon_options.update( + { + CONF_ADDON_DEVICE: "/dev/ttyUSB0", + CONF_ADDON_SOCKET: None, + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + assert result["menu_options"] == ["intent_recommended", "intent_custom"] + + with ( + patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["result"].unique_id == str(ESPHOME_DISCOVERY_INFO.zwave_home_id) + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": None, + "socket_path": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + "use_addon": True, + "integration_created_addon": False, + } + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ), + ) + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") +async def test_esphome_discovery_already_configured( + hass: HomeAssistant, + set_addon_options: AsyncMock, + addon_options: dict[str, Any], +) -> None: + """Test ESPHome discovery success path.""" + addon_options[CONF_ADDON_SOCKET] = "esphome://existing-device:6053" + addon_options["another_key"] = "should_not_be_touched" + + entry = MockConfigEntry( + entry_id="mock-entry-id", + domain=DOMAIN, + data={ + CONF_SOCKET_PATH: "esphome://existing-device:6053", + "use_addon": True, + "integration_created_addon": True, + }, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + mock_reload.assert_called_once_with(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Addon got updated + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "another_key": "should_not_be_touched", + } + ), + ) + + +@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") +async def test_esphome_discovery_usb_same_home_id( + hass: HomeAssistant, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, +) -> None: + """Test ESPHome discovery works if USB stick with same home ID is configured.""" + entry = MockConfigEntry( + entry_id="mock-entry-id", + domain=DOMAIN, + data={ + CONF_USB_PATH: "/dev/ttyUSB0", + "use_addon": True, + "integration_created_addon": True, + }, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + assert result["menu_options"] == ["intent_recommended", "intent_custom"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + + assert result["step_id"] == "install_addon" + assert result["type"] is FlowResultType.SHOW_PROGRESS + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert install_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, + ) + + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert entry.data == { + "url": "ws://host1:3001", + "usb_path": None, + "socket_path": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + "use_addon": True, + "integration_created_addon": True, + } + + +@pytest.mark.usefixtures("supervisor") +async def test_esphome_discovery_not_hassio(hass: HomeAssistant) -> None: + """Test ESPHome discovery aborts when not hassio.""" + with patch( + "homeassistant.components.zwave_js.config_flow.is_hassio", return_value=False + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_hassio" + + @pytest.mark.usefixtures("supervisor", "addon_installed") async def test_discovery_addon_not_running( hass: HomeAssistant, @@ -1239,6 +1635,7 @@ async def test_discovery_addon_not_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1358,6 +1755,7 @@ async def test_discovery_addon_not_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1552,6 +1950,7 @@ async def test_not_addon(hass: HomeAssistant) -> None: assert result["data"] == { "url": "ws://localhost:3000", "usb_path": None, + "socket_path": None, "s0_legacy_key": None, "s2_access_control_key": None, "s2_authenticated_key": None, @@ -1612,6 +2011,7 @@ async def test_addon_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1772,6 +2172,7 @@ async def test_addon_running_already_configured( assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test_new" + assert entry.data["socket_path"] is None assert entry.data["s0_legacy_key"] == "new123" assert entry.data["s2_access_control_key"] == "new456" assert entry.data["s2_authenticated_key"] == "new789" @@ -1879,6 +2280,7 @@ async def test_addon_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2307,6 +2709,7 @@ async def test_addon_installed_already_configured( assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/new" + assert entry.data["socket_path"] is None assert entry.data["s0_legacy_key"] == "new123" assert entry.data["s2_access_control_key"] == "new456" assert entry.data["s2_authenticated_key"] == "new789" @@ -2424,6 +2827,7 @@ async def test_addon_not_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2717,6 +3121,7 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( ( "entry_data", "old_addon_options", + "form_data", "new_addon_options", "disconnect_calls", ), @@ -2742,6 +3147,15 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, ), ( @@ -2765,6 +3179,15 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 1, ), ], @@ -2778,6 +3201,7 @@ async def test_reconfigure_addon_running( restart_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, ) -> None: @@ -2812,11 +3236,9 @@ async def test_reconfigure_addon_running( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=new_addon_options), @@ -2835,7 +3257,8 @@ async def test_reconfigure_addon_running( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["usb_path"] == new_addon_options.get("device") + assert entry.data["socket_path"] == new_addon_options.get("socket") assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] assert ( entry.data["s2_access_control_key"] @@ -2864,7 +3287,7 @@ async def test_reconfigure_addon_running( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( - ("entry_data", "old_addon_options", "new_addon_options"), + ("entry_data", "old_addon_options", "form_data", "new_addon_options"), [ ( {}, @@ -2887,6 +3310,15 @@ async def test_reconfigure_addon_running( "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", }, + { + "device": "/test", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", + }, ), ], ) @@ -2899,6 +3331,7 @@ async def test_reconfigure_addon_running_no_changes( restart_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], ) -> None: """Test reconfigure flow without changes, and add-on already running on Supervisor.""" @@ -2932,19 +3365,18 @@ async def test_reconfigure_addon_running_no_changes( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) await hass.async_block_till_done() - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_count == 0 assert restart_addon.call_count == 0 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["usb_path"] == new_addon_options.get("device") + assert entry.data["socket_path"] == new_addon_options.get("socket") assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] assert ( entry.data["s2_access_control_key"] @@ -2987,6 +3419,7 @@ async def different_device_server_version(*args): ( "entry_data", "old_addon_options", + "form_data", "new_addon_options", "disconnect_calls", "server_version_side_effect", @@ -3013,6 +3446,48 @@ async def different_device_server_version(*args): "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, + 0, + different_device_server_version, + ), + ( + {}, + { + "device": "/test", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", + }, + { + "socket_path": "esphome://mock-host:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, + { + "socket": "esphome://mock-host:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, different_device_server_version, ), @@ -3027,6 +3502,7 @@ async def test_reconfigure_different_device( restart_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, ) -> None: @@ -3062,12 +3538,10 @@ async def test_reconfigure_different_device( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) assert set_addon_options.call_count == 1 - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=new_addon_options) ) @@ -3114,6 +3588,7 @@ async def test_reconfigure_different_device( ( "entry_data", "old_addon_options", + "form_data", "new_addon_options", "disconnect_calls", "restart_addon_side_effect", @@ -3140,6 +3615,15 @@ async def test_reconfigure_different_device( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, [SupervisorError(), None], ), @@ -3164,6 +3648,15 @@ async def test_reconfigure_different_device( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, [ SupervisorError(), @@ -3181,6 +3674,7 @@ async def test_reconfigure_addon_restart_failed( restart_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, ) -> None: @@ -3216,12 +3710,10 @@ async def test_reconfigure_addon_restart_failed( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) assert set_addon_options.call_count == 1 - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=new_addon_options) ) @@ -3319,8 +3811,7 @@ async def test_reconfigure_addon_running_server_info_failure( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], new_addon_options ) await hass.async_block_till_done() @@ -3337,6 +3828,7 @@ async def test_reconfigure_addon_running_server_info_failure( ( "entry_data", "old_addon_options", + "form_data", "new_addon_options", "disconnect_calls", ), @@ -3362,6 +3854,15 @@ async def test_reconfigure_addon_running_server_info_failure( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 0, ), ( @@ -3385,6 +3886,15 @@ async def test_reconfigure_addon_running_server_info_failure( "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", }, + { + "device": "/new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + }, 1, ), ], @@ -3399,6 +3909,7 @@ async def test_reconfigure_addon_not_installed( start_addon: AsyncMock, entry_data: dict[str, Any], old_addon_options: dict[str, Any], + form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, ) -> None: @@ -3443,11 +3954,9 @@ async def test_reconfigure_addon_not_installed( assert result["step_id"] == "configure_addon_reconfigure" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - new_addon_options, + result["flow_id"], form_data ) - new_addon_options["device"] = new_addon_options.pop("usb_path") assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=new_addon_options) ) @@ -3468,7 +3977,7 @@ async def test_reconfigure_addon_not_installed( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["usb_path"] == new_addon_options.get("device") assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is True @@ -3513,6 +4022,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["data"] == { "url": "ws://127.0.0.1:3000", "usb_path": None, + "socket_path": None, "s0_legacy_key": None, "s2_access_control_key": None, "s2_authenticated_key": None, @@ -3578,14 +4088,30 @@ async def test_reconfigure_migrate_low_sdk_version( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( + "form_data", + "new_addon_options", "restore_server_version_side_effect", "final_unique_id", "keep_old_devices", "device_entry_count", ), [ - (None, "3245146787", False, 2), - (aiohttp.ClientError("Boom"), "5678", True, 4), + ( + {CONF_USB_PATH: "/test"}, + {CONF_ADDON_DEVICE: "/test"}, + None, + "3245146787", + False, + 2, + ), + ( + {CONF_SOCKET_PATH: "esphome://1.2.3.4:1234"}, + {CONF_ADDON_SOCKET: "esphome://1.2.3.4:1234"}, + aiohttp.ClientError("Boom"), + "5678", + True, + 4, + ), ], ) async def test_reconfigure_migrate_with_addon( @@ -3598,6 +4124,8 @@ async def test_reconfigure_migrate_with_addon( addon_options: dict[str, Any], set_addon_options: AsyncMock, get_server_version: AsyncMock, + form_data: dict[str, Any], + new_addon_options: dict, restore_server_version_side_effect: Exception | None, final_unique_id: str, keep_old_devices: bool, @@ -3714,26 +4242,17 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "choose_serial_port" - data_schema = result["data_schema"] - assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] - # Ensure the old usb path is not in the list of options - with pytest.raises(InInvalid): - data_schema.schema[CONF_USB_PATH](addon_options["device"]) version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USB_PATH: "/test", - }, + result["flow_id"], form_data ) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": "/test"}) + "core_zwave_js", AddonsOptions(config=new_addon_options) ) # Simulate the new connected controller hardware labels. @@ -3751,17 +4270,19 @@ async def test_reconfigure_migrate_with_addon( assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + # Ensure add-on running would migrate the old settings back into the config entry + with patch("homeassistant.components.zwave_js.async_ensure_addon_running"): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert entry.unique_id == "5678" - get_server_version.side_effect = restore_server_version_side_effect - version_info.home_id = 3245146787 + assert entry.unique_id == "5678" + get_server_version.side_effect = restore_server_version_side_effect + version_info.home_id = 3245146787 - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "restore_nvm" - assert client.connect.call_count == 2 + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 - await hass.async_block_till_done() + await hass.async_block_till_done() assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 @@ -3774,7 +4295,8 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test" + assert entry.data[CONF_USB_PATH] == new_addon_options.get(CONF_ADDON_DEVICE) + assert entry.data[CONF_SOCKET_PATH] == new_addon_options.get(CONF_ADDON_SOCKET) assert entry.data["use_addon"] is True assert ("keep_old_devices" in entry.data) is keep_old_devices assert entry.unique_id == final_unique_id @@ -3931,6 +4453,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" + assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True assert "keep_old_devices" in entry.data assert entry.unique_id == "1234" @@ -4443,8 +4966,9 @@ async def test_intent_recommended_user( assert result["step_id"] == "configure_addon_user" data_schema = result["data_schema"] assert data_schema is not None - assert len(data_schema.schema) == 1 + assert len(data_schema.schema) == 2 assert data_schema.schema.get(CONF_USB_PATH) is not None + assert data_schema.schema.get(CONF_SOCKET_PATH) is not None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -4491,6 +5015,7 @@ async def test_intent_recommended_user( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", + "socket_path": None, "s0_legacy_key": "", "s2_access_control_key": "", "s2_authenticated_key": "", @@ -4601,6 +5126,7 @@ async def test_recommended_usb_discovery( assert result["data"] == { "url": "ws://host1:3001", "usb_path": device, + "socket_path": None, "s0_legacy_key": "", "s2_access_control_key": "", "s2_authenticated_key": "", @@ -4860,6 +5386,7 @@ async def test_addon_rf_region_migrate_network( assert result["reason"] == "migration_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" + assert entry.data["socket_path"] is None assert entry.data["use_addon"] is True assert entry.unique_id == "3245146787" assert client.driver.controller.home_id == 3245146787 diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 954d6422399..f58f8427cf2 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -1073,6 +1073,16 @@ async def test_light_color_only( ) await update_color(0, 0, 0) + # Turn off again and make sure last color/brightness is still preserved + # when turning on light again in the next step + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: HSM200_V1_ENTITY}, + blocking=True, + ) + await update_color(0, 0, 0) + client.async_send_command.reset_mock() # Assert that the brightness is preserved when turning on with color @@ -1095,6 +1105,92 @@ async def test_light_color_only( client.async_send_command.reset_mock() + await update_color(0, 0, 123) + + # Turn off twice + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: HSM200_V1_ENTITY}, + blocking=True, + ) + await update_color(0, 0, 0) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: HSM200_V1_ENTITY}, + blocking=True, + ) + await update_color(0, 0, 0) + + state = hass.states.get(HSM200_V1_ENTITY) + assert state.state == STATE_OFF + + client.async_send_command.reset_mock() + + # Assert that turning on after successive off calls works and keeps the last color + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_BRIGHTNESS: 150}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 0, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 0, "blue": 150} + + client.async_send_command.reset_mock() + + await update_color(0, 0, 150) + + # Force the light to turn off + await update_color(0, 0, 0) + + # Turn off already off light, we won't be aware of last color and brightness + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: HSM200_V1_ENTITY}, + blocking=True, + ) + await update_color(0, 0, 0) + + state = hass.states.get(HSM200_V1_ENTITY) + assert state.state == STATE_OFF + + client.async_send_command.reset_mock() + + # Assert that turning on light after off call with unknown off color/brightness state + # works and that light turns on to white with specified brightness + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_BRIGHTNESS: 160}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 0, + "property": "targetColor", + } + assert args["value"] == {"red": 160, "green": 160, "blue": 160} + + client.async_send_command.reset_mock() + + await update_color(160, 160, 160) + # Clear the color value to trigger an unknown state event = Event( type="value updated", diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 7b00a9d0eef..17e48ab6572 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -65,9 +65,11 @@ async def test_zwave_js_value_updated( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -77,10 +79,12 @@ async def test_zwave_js_value_updated( { "trigger": { "platform": trigger_type, - "device_id": device.id, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": "ajar", + "options": { + "device_id": device.id, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": "ajar", + }, }, "action": { "event": "single_from_value_filter", @@ -90,10 +94,12 @@ async def test_zwave_js_value_updated( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": ["closed", "opened"], + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + }, }, "action": { "event": "multiple_from_value_filters", @@ -103,11 +109,13 @@ async def test_zwave_js_value_updated( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": ["closed", "opened"], - "to": ["opened"], + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + "to": ["opened"], + }, }, "action": { "event": "from_and_to_value_filters", @@ -117,9 +125,11 @@ async def test_zwave_js_value_updated( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "boltStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "boltStatus", + }, }, "action": { "event": "different_value", @@ -299,9 +309,11 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -357,9 +369,11 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation_no_nodes( { "trigger": { "platform": trigger_type, - "entity_id": "sensor.test", - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": "sensor.test", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -413,9 +427,11 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation_no_driver( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -506,9 +522,11 @@ async def test_zwave_js_event( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, }, "action": { "event": "node_no_event_data_filter", @@ -518,10 +536,12 @@ async def test_zwave_js_event( { "trigger": { "platform": trigger_type, - "device_id": device.id, - "event_source": "node", - "event": "interview stage completed", - "event_data": {"stageName": "ProtocolInfo"}, + "options": { + "device_id": device.id, + "event_source": "node", + "event": "interview stage completed", + "event_data": {"stageName": "ProtocolInfo"}, + }, }, "action": { "event": "node_event_data_filter", @@ -531,9 +551,11 @@ async def test_zwave_js_event( { "trigger": { "platform": trigger_type, - "config_entry_id": integration.entry_id, - "event_source": "controller", - "event": "inclusion started", + "options": { + "config_entry_id": integration.entry_id, + "event_source": "controller", + "event": "inclusion started", + }, }, "action": { "event": "controller_no_event_data_filter", @@ -543,10 +565,12 @@ async def test_zwave_js_event( { "trigger": { "platform": trigger_type, - "config_entry_id": integration.entry_id, - "event_source": "controller", - "event": "inclusion started", - "event_data": {"strategy": 0}, + "options": { + "config_entry_id": integration.entry_id, + "event_source": "controller", + "event": "inclusion started", + "event_data": {"strategy": 0}, + }, }, "action": { "event": "controller_event_data_filter", @@ -556,9 +580,11 @@ async def test_zwave_js_event( { "trigger": { "platform": trigger_type, - "config_entry_id": integration.entry_id, - "event_source": "driver", - "event": "logging", + "options": { + "config_entry_id": integration.entry_id, + "event_source": "driver", + "event": "logging", + }, }, "action": { "event": "driver_no_event_data_filter", @@ -568,10 +594,12 @@ async def test_zwave_js_event( { "trigger": { "platform": trigger_type, - "config_entry_id": integration.entry_id, - "event_source": "driver", - "event": "logging", - "event_data": {"message": "test"}, + "options": { + "config_entry_id": integration.entry_id, + "event_source": "driver", + "event": "logging", + "event_data": {"message": "test"}, + }, }, "action": { "event": "driver_event_data_filter", @@ -581,10 +609,12 @@ async def test_zwave_js_event( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "value updated", - "event_data": {"args": {"commandClassName": "Door Lock"}}, + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "value updated", + "event_data": {"args": {"commandClassName": "Door Lock"}}, + }, }, "action": { "event": "node_event_data_no_partial_dict_match_filter", @@ -594,11 +624,13 @@ async def test_zwave_js_event( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "value updated", - "event_data": {"args": {"commandClassName": "Door Lock"}}, - "partial_dict_match": True, + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "value updated", + "event_data": {"args": {"commandClassName": "Door Lock"}}, + "partial_dict_match": True, + }, }, "action": { "event": "node_event_data_partial_dict_match_filter", @@ -864,9 +896,11 @@ async def test_zwave_js_event_bypass_dynamic_validation( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, }, "action": { "event": "node_no_event_data_filter", @@ -915,9 +949,11 @@ async def test_zwave_js_event_bypass_dynamic_validation_no_nodes( { "trigger": { "platform": trigger_type, - "entity_id": "sensor.fake", - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": "sensor.fake", + "event_source": "node", + "event": "interview stage completed", + }, }, "action": { "event": "node_no_event_data_filter", @@ -958,9 +994,11 @@ async def test_zwave_js_event_invalid_config_entry_id( { "trigger": { "platform": trigger_type, - "config_entry_id": "not_real_entry_id", - "event_source": "controller", - "event": "inclusion started", + "options": { + "config_entry_id": "not_real_entry_id", + "event_source": "controller", + "event": "inclusion started", + }, }, "action": { "event": "node_no_event_data_filter", @@ -977,24 +1015,28 @@ async def test_zwave_js_event_invalid_config_entry_id( async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: """Test invalid trigger configs.""" with pytest.raises(vol.Invalid): - await TRIGGERS["event"].async_validate_config( + await TRIGGERS["event"].async_validate_complete_config( hass, { "platform": f"{DOMAIN}.event", - "entity_id": "fake.entity", - "event_source": "node", - "event": "value updated", + "options": { + "entity_id": "fake.entity", + "event_source": "node", + "event": "value updated", + }, }, ) with pytest.raises(vol.Invalid): - await TRIGGERS["value_updated"].async_validate_config( + await TRIGGERS["value_updated"].async_validate_complete_config( hass, { "platform": f"{DOMAIN}.value_updated", - "entity_id": "fake.entity", - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": "fake.entity", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, ) @@ -1017,32 +1059,38 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.value_updated", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, ) await hass.config_entries.async_unload(integration.entry_id) # Test full validation for both events - assert await TRIGGERS["value_updated"].async_validate_config( + assert await TRIGGERS["value_updated"].async_validate_complete_config( hass, { "platform": f"{DOMAIN}.value_updated", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, ) - assert await TRIGGERS["event"].async_validate_config( + assert await TRIGGERS["event"].async_validate_complete_config( hass, { "platform": f"{DOMAIN}.event", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, }, ) @@ -1051,9 +1099,11 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.value_updated", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, ) @@ -1061,10 +1111,12 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.value_updated", - "device_id": device.id, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": "ajar", + "options": { + "device_id": device.id, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": "ajar", + }, }, ) @@ -1072,9 +1124,11 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.event", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, }, ) @@ -1082,10 +1136,12 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.event", - "device_id": device.id, - "event_source": "node", - "event": "interview stage completed", - "event_data": {"stageName": "ProtocolInfo"}, + "options": { + "device_id": device.id, + "event_source": "node", + "event": "interview stage completed", + "event_data": {"stageName": "ProtocolInfo"}, + }, }, ) @@ -1093,9 +1149,11 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.event", - "config_entry_id": integration.entry_id, - "event_source": "controller", - "event": "nvm convert progress", + "options": { + "config_entry_id": integration.entry_id, + "event_source": "controller", + "event": "nvm convert progress", + }, }, ) @@ -1125,9 +1183,11 @@ async def test_server_reconnect_event( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": event_name, + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": event_name, + }, }, "action": { "event": "blah", @@ -1205,9 +1265,11 @@ async def test_server_reconnect_value_updated( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -1258,3 +1320,78 @@ async def test_server_reconnect_value_updated( # Make sure the old listener is no longer referenced assert old_listener not in new_node._listeners.get(event_name, []) + + +async def test_zwave_js_old_syntax( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: + """Test zwave_js triggers work with the old syntax.""" + node: Node = lock_schlage_be469 + + zwavejs_event = async_capture_events(hass, "zwavejs_event") + zwavejs_value_updated = async_capture_events(hass, "zwavejs_value_updated") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": f"{DOMAIN}.value_updated", + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "zwavejs_event", + }, + }, + { + "trigger": { + "platform": f"{DOMAIN}.event", + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, + "action": { + "event": "zwavejs_value_updated", + }, + }, + ] + }, + ) + + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(zwavejs_event) == 1 + + event = Event( + type="interview stage completed", + data={ + "source": "node", + "event": "interview stage completed", + "stageName": "NodeInfo", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(zwavejs_value_updated) == 1 diff --git a/tests/conftest.py b/tests/conftest.py index 130ce74dd5b..374e8098bb9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -99,6 +99,7 @@ from homeassistant.helpers import ( translation as translation_helper, ) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.translation import _TranslationsCacheData from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component @@ -158,6 +159,7 @@ asyncio.set_event_loop_policy = lambda policy: None def pytest_addoption(parser: pytest.Parser) -> None: """Register custom pytest options.""" parser.addoption("--dburl", action="store", default="sqlite://") + parser.addoption("--drop-existing-db", action="store_const", const=True) def pytest_configure(config: pytest.Config) -> None: @@ -185,11 +187,13 @@ def pytest_runtest_setup() -> None: destinations will be allowed. freezegun: - Modified to include https://github.com/spulec/freezegun/pull/424 + Modified to include https://github.com/spulec/freezegun/pull/424 and improve class str. """ pytest_socket.socket_allow_hosts(["127.0.0.1"]) pytest_socket.disable_socket(allow_unix_socket=True) + freezegun.api.FakeDate = patch_time.HAFakeDate # type: ignore[attr-defined] + freezegun.api.datetime_to_fakedatetime = patch_time.ha_datetime_to_fakedatetime # type: ignore[attr-defined] freezegun.api.FakeDatetime = patch_time.HAFakeDatetime # type: ignore[attr-defined] @@ -1489,44 +1493,58 @@ def recorder_db_url( assert not hass_fixture_setup db_url = cast(str, pytestconfig.getoption("dburl")) + drop_existing_db = pytestconfig.getoption("drop_existing_db") + + def drop_db() -> None: + import sqlalchemy as sa # noqa: PLC0415 + import sqlalchemy_utils # noqa: PLC0415 + + if db_url.startswith("mysql://"): + made_url = sa.make_url(db_url) + db = made_url.database + engine = sa.create_engine(db_url) + # Check for any open connections to the database before dropping it + # to ensure that InnoDB does not deadlock. + with engine.begin() as connection: + query = sa.text( + "select id FROM information_schema.processlist WHERE db=:db and id != CONNECTION_ID()" + ) + rows = connection.execute(query, parameters={"db": db}).fetchall() + if rows: + raise RuntimeError( + f"Unable to drop database {db} because it is in use by {rows}" + ) + engine.dispose() + sqlalchemy_utils.drop_database(db_url) + elif db_url.startswith("postgresql://"): + sqlalchemy_utils.drop_database(db_url) + if db_url == "sqlite://" and persistent_database: tmp_path = tmp_path_factory.mktemp("recorder") db_url = "sqlite:///" + str(tmp_path / "pytest.db") - elif db_url.startswith("mysql://"): + elif db_url.startswith(("mysql://", "postgresql://")): import sqlalchemy_utils # noqa: PLC0415 - charset = "utf8mb4' COLLATE = 'utf8mb4_unicode_ci" - assert not sqlalchemy_utils.database_exists(db_url) - sqlalchemy_utils.create_database(db_url, encoding=charset) - elif db_url.startswith("postgresql://"): - import sqlalchemy_utils # noqa: PLC0415 + if drop_existing_db and sqlalchemy_utils.database_exists(db_url): + drop_db() - assert not sqlalchemy_utils.database_exists(db_url) - sqlalchemy_utils.create_database(db_url, encoding="utf8") + if sqlalchemy_utils.database_exists(db_url): + raise RuntimeError( + f"Database {db_url} already exists. Use --drop-existing-db " + "to automatically drop existing database before start of test." + ) + + sqlalchemy_utils.create_database( + db_url, + encoding="utf8mb4' COLLATE = 'utf8mb4_unicode_ci" + if db_url.startswith("mysql://") + else "utf8", + ) yield db_url if db_url == "sqlite://" and persistent_database: rmtree(tmp_path, ignore_errors=True) - elif db_url.startswith("mysql://"): - import sqlalchemy as sa # noqa: PLC0415 - - made_url = sa.make_url(db_url) - db = made_url.database - engine = sa.create_engine(db_url) - # Check for any open connections to the database before dropping it - # to ensure that InnoDB does not deadlock. - with engine.begin() as connection: - query = sa.text( - "select id FROM information_schema.processlist WHERE db=:db and id != CONNECTION_ID()" - ) - rows = connection.execute(query, parameters={"db": db}).fetchall() - if rows: - raise RuntimeError( - f"Unable to drop database {db} because it is in use by {rows}" - ) - engine.dispose() - sqlalchemy_utils.drop_database(db_url) - elif db_url.startswith("postgresql://"): - sqlalchemy_utils.drop_database(db_url) + elif db_url.startswith(("mysql://", "postgresql://")): + drop_db() async def _async_init_recorder_component( @@ -1656,10 +1674,12 @@ async def async_test_recorder( migrate_entity_ids = ( migration.EntityIDMigration.migrate_data if enable_migrate_entity_ids else None ) - legacy_event_id_foreign_key_exists = ( - migration.EventIDPostMigration._legacy_event_id_foreign_key_exists + post_migrate_event_ids = ( + migration.EventIDPostMigration.needs_migrate_impl if enable_migrate_event_ids - else lambda _: None + else lambda _1, _2, _3: migration.DataMigrationStatus( + needs_migrate=False, migration_done=True + ) ) with ( patch( @@ -1698,8 +1718,8 @@ async def async_test_recorder( autospec=True, ), patch( - "homeassistant.components.recorder.migration.EventIDPostMigration._legacy_event_id_foreign_key_exists", - side_effect=legacy_event_id_foreign_key_exists, + "homeassistant.components.recorder.migration.EventIDPostMigration.needs_migrate_impl", + side_effect=post_migrate_event_ids, autospec=True, ), patch( @@ -1857,9 +1877,10 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: # pylint: disable-next=c-extension-no-member bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] - # Mock BlueZ management controller + # Mock BlueZ management controller to successfully setup + # This prevents the manager from operating in degraded mode mock_mgmt_bluetooth_ctl = Mock() - mock_mgmt_bluetooth_ctl.setup = AsyncMock(side_effect=OSError("Mocked error")) + mock_mgmt_bluetooth_ctl.setup = AsyncMock(return_value=None) with ( patch.object( @@ -2090,3 +2111,21 @@ def disable_block_async_io() -> Generator[None]: blocking_call.object, blocking_call.function, blocking_call.original_func ) calls.clear() + + +# Ensure that incorrectly formatted mac addresses are rejected during +# DhcpServiceInfo initialisation +_real_dhcp_service_info_init = DhcpServiceInfo.__init__ + + +def _dhcp_service_info_init(self: DhcpServiceInfo, *args: Any, **kwargs: Any) -> None: + """Override __init__ for DhcpServiceInfo. + + Ensure that the macaddress is always in lowercase and without colons to match DHCP service. + """ + _real_dhcp_service_info_init(self, *args, **kwargs) + if self.macaddress != self.macaddress.lower().replace(":", ""): + raise ValueError("macaddress is not correctly formatted") + + +DhcpServiceInfo.__init__ = _dhcp_service_info_init diff --git a/tests/hassfest/__init__.py b/tests/hassfest/__init__.py index 1ec5a22a567..75d263fa3c8 100644 --- a/tests/hassfest/__init__.py +++ b/tests/hassfest/__init__.py @@ -1 +1,19 @@ """Tests for hassfest.""" + +from pathlib import Path + +from script.hassfest.model import Config, Integration + + +def get_integration(domain: str, config: Config): + """Helper function for creating hassfest integration model instances.""" + return Integration( + Path(domain), + _config=config, + _manifest={ + "domain": domain, + "name": domain, + "documentation": "https://example.com", + "codeowners": ["@awesome"], + }, + ) diff --git a/tests/hassfest/conftest.py b/tests/hassfest/conftest.py new file mode 100644 index 00000000000..86305b799f6 --- /dev/null +++ b/tests/hassfest/conftest.py @@ -0,0 +1,26 @@ +"""Fixtures for hassfest tests.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from script.hassfest.model import Config, Integration + + +@pytest.fixture +def config(): + """Fixture for hassfest Config.""" + return Config( + root=Path(".").absolute(), + specific_integrations=None, + action="validate", + requirements=True, + ) + + +@pytest.fixture +def mock_core_integration(): + """Mock Integration to be a core one.""" + with patch.object(Integration, "core", return_value=True): + yield diff --git a/tests/hassfest/test_conditions.py b/tests/hassfest/test_conditions.py new file mode 100644 index 00000000000..860ef54f951 --- /dev/null +++ b/tests/hassfest/test_conditions.py @@ -0,0 +1,157 @@ +"""Tests for hassfest conditions.""" + +import io +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from homeassistant.util.yaml.loader import parse_yaml +from script.hassfest import conditions +from script.hassfest.model import Config + +from . import get_integration + +CONDITION_DESCRIPTION_FILENAME = "conditions.yaml" +CONDITION_ICONS_FILENAME = "icons.json" +CONDITION_STRINGS_FILENAME = "strings.json" + +CONDITION_DESCRIPTIONS = { + "valid": { + CONDITION_DESCRIPTION_FILENAME: """ + _: + target: + entity: + domain: light + fields: + after: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + after_offset: + selector: + time: null + """, + CONDITION_ICONS_FILENAME: {"conditions": {"_": {"condition": "mdi:flash"}}}, + CONDITION_STRINGS_FILENAME: { + "conditions": { + "_": { + "name": "Sun", + "description": "When the sun is above/below the horizon", + "description_configured": "When a the sun rises or sets.", + "fields": { + "after": {"name": "After event", "description": "The event."}, + "after_offset": { + "name": "Offset", + "description": "The offset.", + }, + }, + } + } + }, + "errors": [], + }, + "yaml_missing_colon": { + CONDITION_DESCRIPTION_FILENAME: """ + test: + fields + entity: + selector: + entity: + """, + "errors": ["Invalid conditions.yaml"], + }, + "invalid_conditions_schema": { + CONDITION_DESCRIPTION_FILENAME: """ + invalid_condition: + fields: + entity: + selector: + invalid_selector: null + """, + "errors": ["Unknown selector type invalid_selector"], + }, + "missing_strings_and_icons": { + CONDITION_DESCRIPTION_FILENAME: """ + sun: + fields: + after: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + translation_key: after + after_offset: + selector: + time: null + """, + CONDITION_ICONS_FILENAME: {"conditions": {}}, + CONDITION_STRINGS_FILENAME: { + "conditions": { + "sun": { + "fields": { + "after_offset": {}, + }, + } + } + }, + "errors": [ + "has no icon", + "has no name", + "has no description", + "field after with no name", + "field after with no description", + "field after with a selector with a translation key", + "field after_offset with no name", + "field after_offset with no description", + ], + }, +} + + +@pytest.mark.usefixtures("mock_core_integration") +def test_validate(config: Config) -> None: + """Test validate version with no key.""" + + def _load_yaml(fname, secrets=None): + domain, yaml_file = fname.split("/") + assert yaml_file == CONDITION_DESCRIPTION_FILENAME + + condition_descriptions = CONDITION_DESCRIPTIONS[domain][yaml_file] + with io.StringIO(condition_descriptions) as file: + return parse_yaml(file) + + def _patched_path_read_text(path: Path): + domain = path.parent.name + filename = path.name + + return json.dumps(CONDITION_DESCRIPTIONS[domain][filename]) + + integrations = { + domain: get_integration(domain, config) for domain in CONDITION_DESCRIPTIONS + } + + with ( + patch("script.hassfest.conditions.grep_dir", return_value=True), + patch("pathlib.Path.is_file", return_value=True), + patch("pathlib.Path.read_text", _patched_path_read_text), + patch("annotatedyaml.loader.load_yaml", side_effect=_load_yaml), + ): + conditions.validate(integrations, config) + + assert not config.errors + + for domain, description in CONDITION_DESCRIPTIONS.items(): + assert len(integrations[domain].errors) == len(description["errors"]), ( + f"Domain '{domain}' has unexpected errors: {integrations[domain].errors}" + ) + for error, expected_error in zip( + integrations[domain].errors, description["errors"], strict=True + ): + assert expected_error in error.error diff --git a/tests/hassfest/test_triggers.py b/tests/hassfest/test_triggers.py new file mode 100644 index 00000000000..9cf327a0e0e --- /dev/null +++ b/tests/hassfest/test_triggers.py @@ -0,0 +1,151 @@ +"""Tests for hassfest triggers.""" + +import io +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from homeassistant.util.yaml.loader import parse_yaml +from script.hassfest import triggers +from script.hassfest.model import Config + +from . import get_integration + +TRIGGER_DESCRIPTION_FILENAME = "triggers.yaml" +TRIGGER_ICONS_FILENAME = "icons.json" +TRIGGER_STRINGS_FILENAME = "strings.json" + +TRIGGER_DESCRIPTIONS = { + "valid": { + TRIGGER_DESCRIPTION_FILENAME: """ + _: + fields: + event: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + offset: + selector: + time: null + """, + TRIGGER_ICONS_FILENAME: {"triggers": {"_": {"trigger": "mdi:flash"}}}, + TRIGGER_STRINGS_FILENAME: { + "triggers": { + "_": { + "name": "MQTT", + "description": "When a specific message is received on a given MQTT topic.", + "description_configured": "When an MQTT message has been received", + "fields": { + "event": {"name": "Event", "description": "The event."}, + "offset": {"name": "Offset", "description": "The offset."}, + }, + } + } + }, + "errors": [], + }, + "yaml_missing_colon": { + TRIGGER_DESCRIPTION_FILENAME: """ + test: + fields + entity: + selector: + entity: + """, + "errors": ["Invalid triggers.yaml"], + }, + "invalid_triggers_schema": { + TRIGGER_DESCRIPTION_FILENAME: """ + invalid_trigger: + fields: + entity: + selector: + invalid_selector: null + """, + "errors": ["Unknown selector type invalid_selector"], + }, + "missing_strings_and_icons": { + TRIGGER_DESCRIPTION_FILENAME: """ + sun: + fields: + event: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + translation_key: event + offset: + selector: + time: null + """, + TRIGGER_ICONS_FILENAME: {"triggers": {}}, + TRIGGER_STRINGS_FILENAME: { + "triggers": { + "sun": { + "fields": { + "offset": {}, + }, + } + } + }, + "errors": [ + "has no icon", + "has no name", + "has no description", + "field event with no name", + "field event with no description", + "field event with a selector with a translation key", + "field offset with no name", + "field offset with no description", + ], + }, +} + + +@pytest.mark.usefixtures("mock_core_integration") +def test_validate(config: Config) -> None: + """Test validate version with no key.""" + + def _load_yaml(fname, secrets=None): + domain, yaml_file = fname.split("/") + assert yaml_file == TRIGGER_DESCRIPTION_FILENAME + + trigger_descriptions = TRIGGER_DESCRIPTIONS[domain][yaml_file] + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + def _patched_path_read_text(path: Path): + domain = path.parent.name + filename = path.name + + return json.dumps(TRIGGER_DESCRIPTIONS[domain][filename]) + + integrations = { + domain: get_integration(domain, config) for domain in TRIGGER_DESCRIPTIONS + } + + with ( + patch("script.hassfest.triggers.grep_dir", return_value=True), + patch("pathlib.Path.is_file", return_value=True), + patch("pathlib.Path.read_text", _patched_path_read_text), + patch("annotatedyaml.loader.load_yaml", side_effect=_load_yaml), + ): + triggers.validate(integrations, config) + + assert not config.errors + + for domain, description in TRIGGER_DESCRIPTIONS.items(): + assert len(integrations[domain].errors) == len(description["errors"]), ( + f"Domain '{domain}' has unexpected errors: {integrations[domain].errors}" + ) + for error, expected_error in zip( + integrations[domain].errors, description["errors"], strict=True + ): + assert expected_error in error.error diff --git a/tests/helpers/template/__init__.py b/tests/helpers/template/__init__.py new file mode 100644 index 00000000000..f1e980fd2fb --- /dev/null +++ b/tests/helpers/template/__init__.py @@ -0,0 +1 @@ +"""Tests for Home Assistant template engine.""" diff --git a/tests/helpers/template/extensions/__init__.py b/tests/helpers/template/extensions/__init__.py new file mode 100644 index 00000000000..43b7c1caccf --- /dev/null +++ b/tests/helpers/template/extensions/__init__.py @@ -0,0 +1 @@ +"""Tests for Home Assistant template extensions.""" diff --git a/tests/helpers/template/extensions/test_base64.py b/tests/helpers/template/extensions/test_base64.py new file mode 100644 index 00000000000..b0c1fb35134 --- /dev/null +++ b/tests/helpers/template/extensions/test_base64.py @@ -0,0 +1,43 @@ +"""Test base64 encoding and decoding functions for Home Assistant templates.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template + + +@pytest.mark.parametrize( + ("value_template", "expected"), + [ + ('{{ "homeassistant" | base64_encode }}', "aG9tZWFzc2lzdGFudA=="), + ("{{ int('0F010003', base=16) | pack('>I') | base64_encode }}", "DwEAAw=="), + ("{{ 'AA01000200150020' | from_hex | base64_encode }}", "qgEAAgAVACA="), + ], +) +def test_base64_encode(hass: HomeAssistant, value_template: str, expected: str) -> None: + """Test the base64_encode filter.""" + assert template.Template(value_template, hass).async_render() == expected + + +def test_base64_decode(hass: HomeAssistant) -> None: + """Test the base64_decode filter.""" + assert ( + template.Template( + '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode }}', hass + ).async_render() + == "homeassistant" + ) + assert ( + template.Template( + '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode(None) }}', hass + ).async_render() + == b"homeassistant" + ) + assert ( + template.Template( + '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode("ascii") }}', hass + ).async_render() + == "homeassistant" + ) diff --git a/tests/helpers/template/extensions/test_collection.py b/tests/helpers/template/extensions/test_collection.py new file mode 100644 index 00000000000..88cdb00dd19 --- /dev/null +++ b/tests/helpers/template/extensions/test_collection.py @@ -0,0 +1,357 @@ +"""Test collection extension.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], True), + ({"a": 1}, False), + ({1, 2, 3}, False), + ((1, 2, 3), False), + ("abc", False), + ("", False), + (5, False), + (None, False), + ({"foo": "bar", "baz": "qux"}, False), + ], +) +def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test list test.""" + assert ( + template.Template("{{ value is list }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], False), + ({"a": 1}, False), + ({1, 2, 3}, True), + ((1, 2, 3), False), + ("abc", False), + ("", False), + (5, False), + (None, False), + ({"foo": "bar", "baz": "qux"}, False), + ], +) +def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test set test.""" + assert ( + template.Template("{{ value is set }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], False), + ({"a": 1}, False), + ({1, 2, 3}, False), + ((1, 2, 3), True), + ("abc", False), + ("", False), + (5, False), + (None, False), + ({"foo": "bar", "baz": "qux"}, False), + ], +) +def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test tuple test.""" + assert ( + template.Template("{{ value is tuple }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], {"expected0": {1, 2, 3}}), + ({"a": 1}, {"expected1": {"a"}}), + ({1, 2, 3}, {"expected2": {1, 2, 3}}), + ((1, 2, 3), {"expected3": {1, 2, 3}}), + ("abc", {"expected4": {"a", "b", "c"}}), + ("", {"expected5": set()}), + (range(3), {"expected6": {0, 1, 2}}), + ({"foo": "bar", "baz": "qux"}, {"expected7": {"foo", "baz"}}), + ], +) +def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test set conversion.""" + assert ( + template.Template("{{ set(value) }}", hass).async_render({"value": value}) + == list(expected.values())[0] + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], {"expected0": (1, 2, 3)}), + ({"a": 1}, {"expected1": ("a",)}), + ({1, 2, 3}, {"expected2": (1, 2, 3)}), # Note: set order is not guaranteed + ((1, 2, 3), {"expected3": (1, 2, 3)}), + ("abc", {"expected4": ("a", "b", "c")}), + ("", {"expected5": ()}), + (range(3), {"expected6": (0, 1, 2)}), + ({"foo": "bar", "baz": "qux"}, {"expected7": ("foo", "baz")}), + ], +) +def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test tuple conversion.""" + result = template.Template("{{ tuple(value) }}", hass).async_render( + {"value": value} + ) + expected_value = list(expected.values())[0] + if isinstance(value, set): # Sets don't have predictable order + assert set(result) == set(expected_value) + else: + assert result == expected_value + + +@pytest.mark.parametrize( + ("cola", "colb", "expected"), + [ + ([1, 2], [3, 4], [(1, 3), (2, 4)]), + ([1, 2], [3, 4, 5], [(1, 3), (2, 4)]), + ([1, 2, 3, 4], [3, 4], [(1, 3), (2, 4)]), + ], +) +def test_zip(hass: HomeAssistant, cola, colb, expected) -> None: + """Test zip.""" + assert ( + template.Template("{{ zip(cola, colb) | list }}", hass).async_render( + {"cola": cola, "colb": colb} + ) + == expected + ) + assert ( + template.Template( + "[{% for a, b in zip(cola, colb) %}({{a}}, {{b}}), {% endfor %}]", hass + ).async_render({"cola": cola, "colb": colb}) + == expected + ) + + +@pytest.mark.parametrize( + ("col", "expected"), + [ + ([(1, 3), (2, 4)], [(1, 2), (3, 4)]), + (["ax", "by", "cz"], [("a", "b", "c"), ("x", "y", "z")]), + ], +) +def test_unzip(hass: HomeAssistant, col, expected) -> None: + """Test unzipping using zip.""" + assert ( + template.Template("{{ zip(*col) | list }}", hass).async_render({"col": col}) + == expected + ) + assert ( + template.Template( + "{% set a, b = zip(*col) %}[{{a}}, {{b}}]", hass + ).async_render({"col": col}) + == expected + ) + + +def test_shuffle(hass: HomeAssistant) -> None: + """Test shuffle.""" + # Test basic shuffle + result = template.Template("{{ shuffle([1, 2, 3, 4, 5]) }}", hass).async_render() + assert len(result) == 5 + assert set(result) == {1, 2, 3, 4, 5} + + # Test shuffle with seed + result1 = template.Template( + "{{ shuffle([1, 2, 3, 4, 5], seed=42) }}", hass + ).async_render() + result2 = template.Template( + "{{ shuffle([1, 2, 3, 4, 5], seed=42) }}", hass + ).async_render() + assert result1 == result2 # Same seed should give same result + + # Test shuffle with different seed + result3 = template.Template( + "{{ shuffle([1, 2, 3, 4, 5], seed=123) }}", hass + ).async_render() + # Different seeds should usually give different results + # (but we can't guarantee it for small lists) + assert len(result3) == 5 + assert set(result3) == {1, 2, 3, 4, 5} + + +def test_flatten(hass: HomeAssistant) -> None: + """Test flatten.""" + # Test basic flattening + assert template.Template( + "{{ flatten([[1, 2], [3, 4]]) }}", hass + ).async_render() == [1, 2, 3, 4] + + # Test nested flattening + assert template.Template( + "{{ flatten([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) }}", hass + ).async_render() == [1, 2, 3, 4, 5, 6, 7, 8] + + # Test flattening with levels + assert template.Template( + "{{ flatten([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], levels=1) }}", hass + ).async_render() == [[1, 2], [3, 4], [5, 6], [7, 8]] + + # Test mixed types + assert template.Template( + "{{ flatten([[1, 'a'], [2, 'b']]) }}", hass + ).async_render() == [1, "a", 2, "b"] + + # Test empty list + assert template.Template("{{ flatten([]) }}", hass).async_render() == [] + + # Test single level + assert template.Template("{{ flatten([1, 2, 3]) }}", hass).async_render() == [ + 1, + 2, + 3, + ] + + +def test_intersect(hass: HomeAssistant) -> None: + """Test intersect.""" + # Test basic intersection + result = template.Template( + "{{ [1, 2, 3, 4] | intersect([3, 4, 5, 6]) | sort }}", hass + ).async_render() + assert result == [3, 4] + + # Test no intersection + result = template.Template("{{ [1, 2] | intersect([3, 4]) }}", hass).async_render() + assert result == [] + + # Test string intersection + result = template.Template( + "{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) | sort }}", hass + ).async_render() + assert result == ["b", "c"] + + # Test empty list intersection + result = template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render() + assert result == [] + + +def test_difference(hass: HomeAssistant) -> None: + """Test difference.""" + # Test basic difference + result = template.Template( + "{{ [1, 2, 3, 4] | difference([3, 4, 5, 6]) | sort }}", hass + ).async_render() + assert result == [1, 2] + + # Test no difference + result = template.Template( + "{{ [1, 2] | difference([1, 2, 3, 4]) }}", hass + ).async_render() + assert result == [] + + # Test string difference + result = template.Template( + "{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) | sort }}", hass + ).async_render() + assert result == ["a"] + + # Test empty list difference + result = template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render() + assert result == [] + + +def test_union(hass: HomeAssistant) -> None: + """Test union.""" + # Test basic union + result = template.Template( + "{{ [1, 2, 3] | union([3, 4, 5]) | sort }}", hass + ).async_render() + assert result == [1, 2, 3, 4, 5] + + # Test string union + result = template.Template( + "{{ ['a', 'b'] | union(['b', 'c']) | sort }}", hass + ).async_render() + assert result == ["a", "b", "c"] + + # Test empty list union + result = template.Template( + "{{ [] | union([1, 2, 3]) | sort }}", hass + ).async_render() + assert result == [1, 2, 3] + + # Test duplicate elements + result = template.Template( + "{{ [1, 1, 2, 2] | union([2, 2, 3, 3]) | sort }}", hass + ).async_render() + assert result == [1, 2, 3] + + +def test_symmetric_difference(hass: HomeAssistant) -> None: + """Test symmetric_difference.""" + # Test basic symmetric difference + result = template.Template( + "{{ [1, 2, 3, 4] | symmetric_difference([3, 4, 5, 6]) | sort }}", hass + ).async_render() + assert result == [1, 2, 5, 6] + + # Test no symmetric difference (identical sets) + result = template.Template( + "{{ [1, 2, 3] | symmetric_difference([1, 2, 3]) }}", hass + ).async_render() + assert result == [] + + # Test string symmetric difference + result = template.Template( + "{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) | sort }}", hass + ).async_render() + assert result == ["a", "d"] + + # Test empty list symmetric difference + result = template.Template( + "{{ [] | symmetric_difference([1, 2, 3]) | sort }}", hass + ).async_render() + assert result == [1, 2, 3] + + +def test_collection_functions_as_tests(hass: HomeAssistant) -> None: + """Test that type checking functions work as tests.""" + # Test various type checking functions + assert template.Template("{{ [1,2,3] is list }}", hass).async_render() + assert template.Template("{{ set([1,2,3]) is set }}", hass).async_render() + assert template.Template("{{ (1,2,3) is tuple }}", hass).async_render() + + +def test_collection_error_handling(hass: HomeAssistant) -> None: + """Test error handling in collection functions.""" + + # Test flatten with non-iterable + with pytest.raises(TemplateError, match="flatten expected a list"): + template.Template("{{ flatten(123) }}", hass).async_render() + + # Test intersect with non-iterable + with pytest.raises(TemplateError, match="intersect expected a list"): + template.Template("{{ [1, 2] | intersect(123) }}", hass).async_render() + + # Test difference with non-iterable + with pytest.raises(TemplateError, match="difference expected a list"): + template.Template("{{ [1, 2] | difference(123) }}", hass).async_render() + + # Test shuffle with no arguments + with pytest.raises(TemplateError, match="shuffle expected at least 1 argument"): + template.Template("{{ shuffle() }}", hass).async_render() diff --git a/tests/helpers/template/extensions/test_crypto.py b/tests/helpers/template/extensions/test_crypto.py new file mode 100644 index 00000000000..f1e4c3b39cc --- /dev/null +++ b/tests/helpers/template/extensions/test_crypto.py @@ -0,0 +1,58 @@ +"""Test cryptographic hash functions for Home Assistant templates.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template + + +def test_md5(hass: HomeAssistant) -> None: + """Test the md5 function and filter.""" + assert ( + template.Template("{{ md5('Home Assistant') }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + assert ( + template.Template("{{ 'Home Assistant' | md5 }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + +def test_sha1(hass: HomeAssistant) -> None: + """Test the sha1 function and filter.""" + assert ( + template.Template("{{ sha1('Home Assistant') }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha1 }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + +def test_sha256(hass: HomeAssistant) -> None: + """Test the sha256 function and filter.""" + assert ( + template.Template("{{ sha256('Home Assistant') }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha256 }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + +def test_sha512(hass: HomeAssistant) -> None: + """Test the sha512 function and filter.""" + assert ( + template.Template("{{ sha512('Home Assistant') }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) diff --git a/tests/helpers/template/extensions/test_math.py b/tests/helpers/template/extensions/test_math.py new file mode 100644 index 00000000000..5a873095181 --- /dev/null +++ b/tests/helpers/template/extensions/test_math.py @@ -0,0 +1,393 @@ +"""Test mathematical and statistical functions for Home Assistant templates.""" + +from __future__ import annotations + +import math + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template + + +def render(hass: HomeAssistant, template_str: str) -> str: + """Render template and return result.""" + return template.Template(template_str, hass).async_render() + + +def test_math_constants(hass: HomeAssistant) -> None: + """Test math constants.""" + assert render(hass, "{{ e }}") == math.e + assert render(hass, "{{ pi }}") == math.pi + assert render(hass, "{{ tau }}") == math.pi * 2 + + +def test_logarithm(hass: HomeAssistant) -> None: + """Test logarithm.""" + tests = [ + (4, 2, 2.0), + (1000, 10, 3.0), + (math.e, "", 1.0), # The "" means the default base (e) will be used + ] + + for value, base, expected in tests: + assert ( + template.Template( + f"{{{{ {value} | log({base}) | round(1) }}}}", hass + ).async_render() + == expected + ) + + assert ( + template.Template( + f"{{{{ log({value}, {base}) | round(1) }}}}", hass + ).async_render() + == expected + ) + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ invalid | log(_) }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ log(invalid, _) }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ 10 | log(invalid) }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ log(10, invalid) }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | log(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | log(10, default=1) }}") == 1 + assert render(hass, "{{ log('no_number', 10, 1) }}") == 1 + assert render(hass, "{{ log('no_number', 10, default=1) }}") == 1 + assert render(hass, "{{ log(0, 10, 1) }}") == 1 + assert render(hass, "{{ log(0, 10, default=1) }}") == 1 + + +def test_sine(hass: HomeAssistant) -> None: + """Test sine.""" + tests = [ + (0, 0.0), + (math.pi / 2, 1.0), + (math.pi, 0.0), + (math.pi * 1.5, -1.0), + (math.pi / 10, 0.309), + ] + + for value, expected in tests: + assert ( + template.Template( + f"{{{{ {value} | sin | round(3) }}}}", hass + ).async_render() + == expected + ) + assert render(hass, f"{{{{ sin({value}) | round(3) }}}}") == expected + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ 'duck' | sin }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ invalid | sin('duck') }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sin(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1 + assert render(hass, "{{ sin('no_number', 1) }}") == 1 + assert render(hass, "{{ sin('no_number', default=1) }}") == 1 + + +def test_cosine(hass: HomeAssistant) -> None: + """Test cosine.""" + tests = [ + (0, 1.0), + (math.pi / 2, 0.0), + (math.pi, -1.0), + (math.pi * 1.5, 0.0), + (math.pi / 3, 0.5), + ] + + for value, expected in tests: + assert ( + template.Template( + f"{{{{ {value} | cos | round(3) }}}}", hass + ).async_render() + == expected + ) + assert render(hass, f"{{{{ cos({value}) | round(3) }}}}") == expected + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ 'duck' | cos }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | cos(1) }}") == 1 + assert render(hass, "{{ 'no_number' | cos(default=1) }}") == 1 + assert render(hass, "{{ cos('no_number', 1) }}") == 1 + assert render(hass, "{{ cos('no_number', default=1) }}") == 1 + + +def test_tangent(hass: HomeAssistant) -> None: + """Test tangent.""" + tests = [ + (0, 0.0), + (math.pi / 4, 1.0), + (math.pi, 0.0), + (math.pi / 6, 0.577), + ] + + for value, expected in tests: + assert ( + template.Template( + f"{{{{ {value} | tan | round(3) }}}}", hass + ).async_render() + == expected + ) + assert render(hass, f"{{{{ tan({value}) | round(3) }}}}") == expected + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ 'duck' | tan }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | tan(1) }}") == 1 + assert render(hass, "{{ 'no_number' | tan(default=1) }}") == 1 + assert render(hass, "{{ tan('no_number', 1) }}") == 1 + assert render(hass, "{{ tan('no_number', default=1) }}") == 1 + + +def test_square_root(hass: HomeAssistant) -> None: + """Test square root.""" + tests = [ + (0, 0.0), + (1, 1.0), + (4, 2.0), + (9, 3.0), + (16, 4.0), + (0.25, 0.5), + ] + + for value, expected in tests: + assert ( + template.Template(f"{{{{ {value} | sqrt }}}}", hass).async_render() + == expected + ) + assert render(hass, f"{{{{ sqrt({value}) }}}}") == expected + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ 'duck' | sqrt }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ -1 | sqrt }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sqrt(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sqrt(default=1) }}") == 1 + assert render(hass, "{{ sqrt('no_number', 1) }}") == 1 + assert render(hass, "{{ sqrt('no_number', default=1) }}") == 1 + assert render(hass, "{{ sqrt(-1, 1) }}") == 1 + assert render(hass, "{{ sqrt(-1, default=1) }}") == 1 + + +def test_arc_functions(hass: HomeAssistant) -> None: + """Test arc trigonometric functions.""" + # Test arc sine + assert render(hass, "{{ asin(0.5) | round(3) }}") == round(math.asin(0.5), 3) + assert render(hass, "{{ 0.5 | asin | round(3) }}") == round(math.asin(0.5), 3) + + # Test arc cosine + assert render(hass, "{{ acos(0.5) | round(3) }}") == round(math.acos(0.5), 3) + assert render(hass, "{{ 0.5 | acos | round(3) }}") == round(math.acos(0.5), 3) + + # Test arc tangent + assert render(hass, "{{ atan(1) | round(3) }}") == round(math.atan(1), 3) + assert render(hass, "{{ 1 | atan | round(3) }}") == round(math.atan(1), 3) + + # Test atan2 + assert render(hass, "{{ atan2(1, 1) | round(3) }}") == round(math.atan2(1, 1), 3) + assert render(hass, "{{ atan2([1, 1]) | round(3) }}") == round(math.atan2(1, 1), 3) + + # Test invalid input handling + with pytest.raises(TemplateError): + render(hass, "{{ asin(2) }}") # Outside domain [-1, 1] + + # Test default values + assert render(hass, "{{ asin(2, 1) }}") == 1 + assert render(hass, "{{ acos(2, 1) }}") == 1 + assert render(hass, "{{ atan('invalid', 1) }}") == 1 + assert render(hass, "{{ atan2('invalid', 1, 1) }}") == 1 + + +def test_average(hass: HomeAssistant) -> None: + """Test the average function.""" + assert template.Template("{{ average([1, 2, 3]) }}", hass).async_render() == 2 + assert template.Template("{{ average(1, 2, 3) }}", hass).async_render() == 2 + + # Testing of default values + assert template.Template("{{ average([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ average([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ average([], default=-1) }}", hass).async_render() == -1 + assert ( + template.Template("{{ average([], 5, default=-1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ average(1, 'a', 3, default=-1) }}", hass).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ average() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ average([]) }}", hass).async_render() + + +def test_median(hass: HomeAssistant) -> None: + """Test the median function.""" + assert template.Template("{{ median([1, 2, 3]) }}", hass).async_render() == 2 + assert template.Template("{{ median([1, 2, 3, 4]) }}", hass).async_render() == 2.5 + assert template.Template("{{ median(1, 2, 3) }}", hass).async_render() == 2 + + # Testing of default values + assert template.Template("{{ median([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ median([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ median([], default=-1) }}", hass).async_render() == -1 + + with pytest.raises(TemplateError): + template.Template("{{ median() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median([]) }}", hass).async_render() + + +def test_statistical_mode(hass: HomeAssistant) -> None: + """Test the statistical mode function.""" + assert ( + template.Template("{{ statistical_mode([1, 1, 2, 3]) }}", hass).async_render() + == 1 + ) + assert ( + template.Template("{{ statistical_mode(1, 1, 2, 3) }}", hass).async_render() + == 1 + ) + + # Testing of default values + assert ( + template.Template("{{ statistical_mode([1, 1, 2], -1) }}", hass).async_render() + == 1 + ) + assert ( + template.Template("{{ statistical_mode([], -1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ statistical_mode([], default=-1) }}", hass).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode([]) }}", hass).async_render() + + +def test_min_max_functions(hass: HomeAssistant) -> None: + """Test min and max functions.""" + # Test min function + assert template.Template("{{ min([1, 2, 3]) }}", hass).async_render() == 1 + assert template.Template("{{ min(1, 2, 3) }}", hass).async_render() == 1 + + # Test max function + assert template.Template("{{ max([1, 2, 3]) }}", hass).async_render() == 3 + assert template.Template("{{ max(1, 2, 3) }}", hass).async_render() == 3 + + # Test error handling + with pytest.raises(TemplateError): + template.Template("{{ min() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ max() }}", hass).async_render() + + +def test_bitwise_and(hass: HomeAssistant) -> None: + """Test bitwise and.""" + assert template.Template("{{ bitwise_and(8, 2) }}", hass).async_render() == 0 + assert template.Template("{{ bitwise_and(10, 2) }}", hass).async_render() == 2 + assert template.Template("{{ bitwise_and(8, 8) }}", hass).async_render() == 8 + + +def test_bitwise_or(hass: HomeAssistant) -> None: + """Test bitwise or.""" + assert template.Template("{{ bitwise_or(8, 2) }}", hass).async_render() == 10 + assert template.Template("{{ bitwise_or(8, 8) }}", hass).async_render() == 8 + assert template.Template("{{ bitwise_or(10, 2) }}", hass).async_render() == 10 + + +def test_bitwise_xor(hass: HomeAssistant) -> None: + """Test bitwise xor.""" + assert template.Template("{{ bitwise_xor(8, 2) }}", hass).async_render() == 10 + assert template.Template("{{ bitwise_xor(8, 8) }}", hass).async_render() == 0 + assert template.Template("{{ bitwise_xor(10, 2) }}", hass).async_render() == 8 + + +@pytest.mark.parametrize( + "attribute", + [ + "a", + "b", + "c", + ], +) +def test_min_max_attribute(hass: HomeAssistant, attribute) -> None: + """Test the min and max filters with attribute.""" + hass.states.async_set( + "test.object", + "test", + { + "objects": [ + { + "a": 1, + "b": 2, + "c": 3, + }, + { + "a": 2, + "b": 1, + "c": 2, + }, + { + "a": 3, + "b": 3, + "c": 1, + }, + ], + }, + ) + assert ( + template.Template( + f"{{{{ (state_attr('test.object', 'objects') | min(attribute='{attribute}'))['{attribute}']}}}}", + hass, + ).async_render() + == 1 + ) + assert ( + template.Template( + f"{{{{ (min(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", + hass, + ).async_render() + == 1 + ) + assert ( + template.Template( + f"{{{{ (state_attr('test.object', 'objects') | max(attribute='{attribute}'))['{attribute}']}}}}", + hass, + ).async_render() + == 3 + ) + assert ( + template.Template( + f"{{{{ (max(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", + hass, + ).async_render() + == 3 + ) diff --git a/tests/helpers/template/extensions/test_regex.py b/tests/helpers/template/extensions/test_regex.py new file mode 100644 index 00000000000..290b55bad1f --- /dev/null +++ b/tests/helpers/template/extensions/test_regex.py @@ -0,0 +1,265 @@ +"""Test regex template extension.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template + + +def test_regex_match(hass: HomeAssistant) -> None: + """Test regex_match method.""" + tpl = template.Template( + r""" +{{ '123-456-7890' | regex_match('(\\d{3})-(\\d{3})-(\\d{4})') }} + """, + hass, + ) + assert tpl.async_render() is True + + tpl = template.Template( + """ +{{ 'Home Assistant test' | regex_match('home', True) }} + """, + hass, + ) + assert tpl.async_render() is True + + tpl = template.Template( + """ + {{ 'Another Home Assistant test' | regex_match('Home') }} + """, + hass, + ) + assert tpl.async_render() is False + + tpl = template.Template( + """ +{{ ['Home Assistant test'] | regex_match('.*Assist') }} + """, + hass, + ) + assert tpl.async_render() is True + + +def test_match_test(hass: HomeAssistant) -> None: + """Test match test.""" + tpl = template.Template( + r""" +{{ '123-456-7890' is match('(\\d{3})-(\\d{3})-(\\d{4})') }} + """, + hass, + ) + assert tpl.async_render() is True + + +def test_regex_search(hass: HomeAssistant) -> None: + """Test regex_search method.""" + tpl = template.Template( + r""" +{{ '123-456-7890' | regex_search('(\\d{3})-(\\d{3})-(\\d{4})') }} + """, + hass, + ) + assert tpl.async_render() is True + + tpl = template.Template( + """ +{{ 'Home Assistant test' | regex_search('home', True) }} + """, + hass, + ) + assert tpl.async_render() is True + + tpl = template.Template( + """ + {{ 'Another Home Assistant test' | regex_search('Home') }} + """, + hass, + ) + assert tpl.async_render() is True + + tpl = template.Template( + """ +{{ ['Home Assistant test'] | regex_search('Assist') }} + """, + hass, + ) + assert tpl.async_render() is True + + +def test_search_test(hass: HomeAssistant) -> None: + """Test search test.""" + tpl = template.Template( + r""" +{{ '123-456-7890' is search('(\\d{3})-(\\d{3})-(\\d{4})') }} + """, + hass, + ) + assert tpl.async_render() is True + + +def test_regex_replace(hass: HomeAssistant) -> None: + """Test regex_replace method.""" + tpl = template.Template( + r""" +{{ 'Hello World' | regex_replace('(Hello\\s)',) }} + """, + hass, + ) + assert tpl.async_render() == "World" + + tpl = template.Template( + """ +{{ ['Home hinderant test'] | regex_replace('hinder', 'Assist') }} + """, + hass, + ) + assert tpl.async_render() == ["Home Assistant test"] + + +def test_regex_findall(hass: HomeAssistant) -> None: + """Test regex_findall method.""" + tpl = template.Template( + """ +{{ 'Flight from JFK to LHR' | regex_findall('([A-Z]{3})') }} + """, + hass, + ) + assert tpl.async_render() == ["JFK", "LHR"] + + +def test_regex_findall_index(hass: HomeAssistant) -> None: + """Test regex_findall_index method.""" + tpl = template.Template( + """ +{{ 'Flight from JFK to LHR' | regex_findall_index('([A-Z]{3})', 0) }} + """, + hass, + ) + assert tpl.async_render() == "JFK" + + tpl = template.Template( + """ +{{ 'Flight from JFK to LHR' | regex_findall_index('([A-Z]{3})', 1) }} + """, + hass, + ) + assert tpl.async_render() == "LHR" + + +def test_regex_ignorecase_parameter(hass: HomeAssistant) -> None: + """Test ignorecase parameter across all regex functions.""" + # Test regex_match with ignorecase + tpl = template.Template( + """ +{{ 'TEST' | regex_match('test', True) }} + """, + hass, + ) + assert tpl.async_render() is True + + # Test regex_search with ignorecase + tpl = template.Template( + """ +{{ 'TEST STRING' | regex_search('test', True) }} + """, + hass, + ) + assert tpl.async_render() is True + + # Test regex_replace with ignorecase + tpl = template.Template( + """ +{{ 'TEST' | regex_replace('test', 'replaced', True) }} + """, + hass, + ) + assert tpl.async_render() == "replaced" + + # Test regex_findall with ignorecase + tpl = template.Template( + """ +{{ 'TEST test Test' | regex_findall('test', True) }} + """, + hass, + ) + assert tpl.async_render() == ["TEST", "test", "Test"] + + +def test_regex_with_non_string_input(hass: HomeAssistant) -> None: + """Test regex functions with non-string input (automatic conversion).""" + # Test with integer + tpl = template.Template( + r""" +{{ 12345 | regex_match('\\d+') }} + """, + hass, + ) + assert tpl.async_render() is True + + # Test with list (string conversion) + tpl = template.Template( + r""" +{{ [1, 2, 3] | regex_search('\\d') }} + """, + hass, + ) + assert tpl.async_render() is True + + +def test_regex_edge_cases(hass: HomeAssistant) -> None: + """Test regex functions with edge cases.""" + # Test with empty string + tpl = template.Template( + """ +{{ '' | regex_match('.*') }} + """, + hass, + ) + assert tpl.async_render() is True + + # Test regex_findall_index with out of bounds index + tpl = template.Template( + """ +{{ 'test' | regex_findall_index('t', 5) }} + """, + hass, + ) + with pytest.raises(TemplateError): + tpl.async_render() + + # Test with invalid regex pattern + tpl = template.Template( + """ +{{ 'test' | regex_match('[') }} + """, + hass, + ) + with pytest.raises(TemplateError): # re.error wrapped in TemplateError + tpl.async_render() + + +def test_regex_groups_and_replacement_patterns(hass: HomeAssistant) -> None: + """Test regex with groups and replacement patterns.""" + # Test replacement with groups + tpl = template.Template( + r""" +{{ 'John Doe' | regex_replace('(\\w+) (\\w+)', '\\2, \\1') }} + """, + hass, + ) + assert tpl.async_render() == "Doe, John" + + # Test findall with groups + tpl = template.Template( + r""" +{{ 'Email: test@example.com, Phone: 123-456-7890' | regex_findall('(\\w+@\\w+\\.\\w+)|(\\d{3}-\\d{3}-\\d{4})') }} + """, + hass, + ) + result = tpl.async_render() + # The result will contain tuples with empty strings for non-matching groups + assert len(result) == 2 diff --git a/tests/helpers/template/extensions/test_string.py b/tests/helpers/template/extensions/test_string.py new file mode 100644 index 00000000000..241bf40eef1 --- /dev/null +++ b/tests/helpers/template/extensions/test_string.py @@ -0,0 +1,164 @@ +"""Test string template extension.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template + + +def test_ordinal(hass: HomeAssistant) -> None: + """Test the ordinal filter.""" + tests = [ + (1, "1st"), + (2, "2nd"), + (3, "3rd"), + (4, "4th"), + (5, "5th"), + (12, "12th"), + (100, "100th"), + (101, "101st"), + ] + + for value, expected in tests: + assert ( + template.Template(f"{{{{ {value} | ordinal }}}}", hass).async_render() + == expected + ) + + +def test_slugify(hass: HomeAssistant) -> None: + """Test the slugify filter.""" + # Test as global function + assert ( + template.Template('{{ slugify("Home Assistant") }}', hass).async_render() + == "home_assistant" + ) + + # Test as filter + assert ( + template.Template('{{ "Home Assistant" | slugify }}', hass).async_render() + == "home_assistant" + ) + + # Test with custom separator as global + assert ( + template.Template('{{ slugify("Home Assistant", "-") }}', hass).async_render() + == "home-assistant" + ) + + # Test with custom separator as filter + assert ( + template.Template('{{ "Home Assistant" | slugify("-") }}', hass).async_render() + == "home-assistant" + ) + + +def test_urlencode(hass: HomeAssistant) -> None: + """Test the urlencode method.""" + # Test with dictionary + tpl = template.Template( + "{% set dict = {'foo': 'x&y', 'bar': 42} %}{{ dict | urlencode }}", + hass, + ) + assert tpl.async_render() == "foo=x%26y&bar=42" + + # Test with string + tpl = template.Template( + "{% set string = 'the quick brown fox = true' %}{{ string | urlencode }}", + hass, + ) + assert tpl.async_render() == "the%20quick%20brown%20fox%20%3D%20true" + + +def test_string_functions_with_non_string_input(hass: HomeAssistant) -> None: + """Test string functions with non-string input (automatic conversion).""" + # Test ordinal with integer + assert template.Template("{{ 42 | ordinal }}", hass).async_render() == "42nd" + + # Test slugify with integer - Note: Jinja2 may return integer for simple cases + result = template.Template("{{ 123 | slugify }}", hass).async_render() + # Accept either string or integer result for simple numeric cases + assert result in ["123", 123] + + +def test_ordinal_edge_cases(hass: HomeAssistant) -> None: + """Test ordinal function with edge cases.""" + # Test teens (11th, 12th, 13th should all be 'th') + teens_tests = [ + (11, "11th"), + (12, "12th"), + (13, "13th"), + (111, "111th"), + (112, "112th"), + (113, "113th"), + ] + + for value, expected in teens_tests: + assert ( + template.Template(f"{{{{ {value} | ordinal }}}}", hass).async_render() + == expected + ) + + # Test other numbers ending in 1, 2, 3 + other_tests = [ + (21, "21st"), + (22, "22nd"), + (23, "23rd"), + (121, "121st"), + (122, "122nd"), + (123, "123rd"), + ] + + for value, expected in other_tests: + assert ( + template.Template(f"{{{{ {value} | ordinal }}}}", hass).async_render() + == expected + ) + + +def test_slugify_various_separators(hass: HomeAssistant) -> None: + """Test slugify with various separators.""" + test_cases = [ + ("Hello World", "_", "hello_world"), + ("Hello World", "-", "hello-world"), + ("Hello World", ".", "hello.world"), + ("Hello-World_Test", "~", "hello~world~test"), + ] + + for text, separator, expected in test_cases: + # Test as global function + assert ( + template.Template( + f'{{{{ slugify("{text}", "{separator}") }}}}', hass + ).async_render() + == expected + ) + + # Test as filter + assert ( + template.Template( + f'{{{{ "{text}" | slugify("{separator}") }}}}', hass + ).async_render() + == expected + ) + + +def test_urlencode_various_types(hass: HomeAssistant) -> None: + """Test urlencode with various data types.""" + # Test with nested dictionary values + tpl = template.Template( + "{% set data = {'key': 'value with spaces', 'num': 123} %}{{ data | urlencode }}", + hass, + ) + result = tpl.async_render() + # URL encoding can have different order, so check both parts are present + # Note: urllib.parse.urlencode uses + for spaces in form data + assert "key=value+with+spaces" in result + assert "num=123" in result + + # Test with special characters + tpl = template.Template( + "{% set data = {'special': 'a+b=c&d'} %}{{ data | urlencode }}", + hass, + ) + assert tpl.async_render() == "special=a%2Bb%3Dc%26d" diff --git a/tests/helpers/snapshots/test_template.ambr b/tests/helpers/template/snapshots/test_init.ambr similarity index 100% rename from tests/helpers/snapshots/test_template.ambr rename to tests/helpers/template/snapshots/test_init.ambr diff --git a/tests/helpers/template/test_context.py b/tests/helpers/template/test_context.py new file mode 100644 index 00000000000..7773be5be20 --- /dev/null +++ b/tests/helpers/template/test_context.py @@ -0,0 +1,91 @@ +"""Test template context management for Home Assistant.""" + +from __future__ import annotations + +import jinja2 + +from homeassistant.helpers.template.context import ( + TemplateContextManager, + render_with_context, + template_context_manager, + template_cv, +) + + +def test_template_context_manager() -> None: + """Test TemplateContextManager functionality.""" + cm = TemplateContextManager() + + # Test setting template + cm.set_template("{{ test }}", "rendering") + assert template_cv.get() == ("{{ test }}", "rendering") + + # Test context manager exit + cm.__exit__(None, None, None) + assert template_cv.get() is None + + +def test_template_context_manager_context() -> None: + """Test TemplateContextManager as context manager.""" + cm = TemplateContextManager() + + with cm: + cm.set_template("{{ test }}", "parsing") + assert template_cv.get() == ("{{ test }}", "parsing") + + # Should be cleared after exit + assert template_cv.get() is None + + +def test_global_template_context_manager() -> None: + """Test global template context manager instance.""" + # Should be an instance of TemplateContextManager + assert isinstance(template_context_manager, TemplateContextManager) + + # Test it works like any other context manager + template_context_manager.set_template("{{ global_test }}", "testing") + assert template_cv.get() == ("{{ global_test }}", "testing") + + template_context_manager.__exit__(None, None, None) + assert template_cv.get() is None + + +def test_render_with_context() -> None: + """Test render_with_context function.""" + # Create a simple template + env = jinja2.Environment() + template_obj = env.from_string("Hello {{ name }}!") + + # Test rendering with context tracking + result = render_with_context("Hello {{ name }}!", template_obj, name="World") + assert result == "Hello World!" + + # Context should be cleared after rendering + assert template_cv.get() is None + + +def test_render_with_context_sets_context() -> None: + """Test that render_with_context properly sets template context.""" + # Create a template that we can use to check context + jinja2.Environment() + + # We'll use a custom template class to capture context during rendering + context_during_render = [] + + class MockTemplate: + def render(self, **kwargs): + # Capture the context during rendering + context_during_render.append(template_cv.get()) + return "rendered" + + mock_template = MockTemplate() + + # Render with context + result = render_with_context("{{ test_template }}", mock_template, test=True) + + assert result == "rendered" + # Should have captured the context during rendering + assert len(context_during_render) == 1 + assert context_during_render[0] == ("{{ test_template }}", "rendering") + # Context should be cleared after rendering + assert template_cv.get() is None diff --git a/tests/helpers/template/test_helpers.py b/tests/helpers/template/test_helpers.py new file mode 100644 index 00000000000..64d1c5a9364 --- /dev/null +++ b/tests/helpers/template/test_helpers.py @@ -0,0 +1,14 @@ +"""Test template helper functions.""" + +import pytest + +from homeassistant.helpers.template.helpers import raise_no_default + + +def test_raise_no_default() -> None: + """Test raise_no_default raises ValueError with correct message.""" + with pytest.raises( + ValueError, + match="Template error: test got invalid input 'invalid' when rendering or compiling template '' but no default was specified", + ): + raise_no_default("test", "invalid") diff --git a/tests/helpers/test_template.py b/tests/helpers/template/test_init.py similarity index 81% rename from tests/helpers/test_template.py rename to tests/helpers/template/test_init.py index 85a2673f17d..44399869ef8 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/template/test_init.py @@ -15,7 +15,6 @@ from unittest.mock import patch from freezegun import freeze_time import orjson import pytest -from pytest_unordered import unordered from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -49,6 +48,10 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.template.render_info import ( + ALL_STATES_RATE_LIMIT, + DOMAIN_STATES_RATE_LIMIT, +) from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -128,7 +131,7 @@ async def test_template_render_missing_hass(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test", "23") template_str = "{{ states('sensor.test') }}" template_obj = template.Template(template_str, None) - template._render_info.set(template.RenderInfo(template_obj)) + template.render_info_cv.set(template.RenderInfo(template_obj)) with pytest.raises(RuntimeError, match="hass not set while rendering"): template_obj.async_render_to_info() @@ -144,7 +147,7 @@ async def test_template_render_info_collision(hass: HomeAssistant) -> None: template_str = "{{ states('sensor.test') }}" template_obj = template.Template(template_str, None) template_obj.hass = hass - template._render_info.set(template.RenderInfo(template_obj)) + template.render_info_cv.set(template.RenderInfo(template_obj)) with pytest.raises(RuntimeError, match="RenderInfo already set while rendering"): template_obj.async_render_to_info() @@ -230,7 +233,7 @@ def test_iterating_all_states(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("test.object", "happy") hass.states.async_set("sensor.temperature", 10) @@ -255,7 +258,7 @@ def test_iterating_all_states_unavailable(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("test.object", "unknown") hass.states.async_set("sensor.temperature", 10) @@ -270,7 +273,7 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert_result_info(info, "", domains=["sensor"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT hass.states.async_set("test.object", "happy") hass.states.async_set("sensor.back_door", "open") @@ -514,114 +517,6 @@ def test_isnumber(hass: HomeAssistant, value, expected) -> None: ) -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], True), - ({1, 2}, False), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", False), - (b"abc", False), - ((1, 2), False), - (datetime(2024, 1, 1, 0, 0, 0), False), - ], -) -def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test is list.""" - assert ( - template.Template("{{ value is list }}", hass).async_render({"value": value}) - == expected - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], False), - ({1, 2}, True), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", False), - (b"abc", False), - ((1, 2), False), - (datetime(2024, 1, 1, 0, 0, 0), False), - ], -) -def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test is set.""" - assert ( - template.Template("{{ value is set }}", hass).async_render({"value": value}) - == expected - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], False), - ({1, 2}, False), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", False), - (b"abc", False), - ((1, 2), True), - (datetime(2024, 1, 1, 0, 0, 0), False), - ], -) -def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test is tuple.""" - assert ( - template.Template("{{ value is tuple }}", hass).async_render({"value": value}) - == expected - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], {1, 2}), - ({1, 2}, {1, 2}), - ({"a": 1, "b": 2}, {"a", "b"}), - (ReadOnlyDict({"a": 1, "b": 2}), {"a", "b"}), - (MappingProxyType({"a": 1, "b": 2}), {"a", "b"}), - ("abc", {"a", "b", "c"}), - (b"abc", {97, 98, 99}), - ((1, 2), {1, 2}), - ], -) -def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test convert to set function.""" - assert ( - template.Template("{{ set(value) }}", hass).async_render({"value": value}) - == expected - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], (1, 2)), - ({1, 2}, (1, 2)), - ({"a": 1, "b": 2}, ("a", "b")), - (ReadOnlyDict({"a": 1, "b": 2}), ("a", "b")), - (MappingProxyType({"a": 1, "b": 2}), ("a", "b")), - ("abc", ("a", "b", "c")), - (b"abc", (97, 98, 99)), - ((1, 2), (1, 2)), - ], -) -def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test convert to tuple function.""" - assert ( - template.Template("{{ tuple(value) }}", hass).async_render({"value": value}) - == expected - ) - - def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None: """Test converting a datetime to an iterable raises an error.""" dt_ = datetime(2020, 1, 1, 0, 0, 0) @@ -655,30 +550,6 @@ def test_is_datetime(hass: HomeAssistant, value, expected) -> None: ) -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], False), - ({1, 2}, False), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", True), - (b"abc", True), - ((1, 2), False), - (datetime(2024, 1, 1, 0, 0, 0), False), - ], -) -def test_is_string_like(hass: HomeAssistant, value, expected) -> None: - """Test is string_like.""" - assert ( - template.Template("{{ value is string_like }}", hass).async_render( - {"value": value} - ) - == expected - ) - - def test_rounding_value(hass: HomeAssistant) -> None: """Test rounding value.""" hass.states.async_set("sensor.temperature", 12.78) @@ -795,37 +666,46 @@ def test_apply(hass: HomeAssistant) -> None: def test_apply_macro_with_arguments(hass: HomeAssistant) -> None: """Test apply macro with positional, named, and mixed arguments.""" # Test macro with positional arguments - assert template.Template( - """ - {%- macro greet(name, greeting) -%} - {{ greeting }}, {{ name }}! - {%- endmacro %} - {{ ["Alice", "Bob"] | map('apply', greet, "Hello") | list }} + assert ( + template.Template( + """ + {%- macro add_numbers(a, b, c) -%} + {{ a + b + c }} + {%- endmacro -%} + {{ apply(5, add_numbers, 10, 15) }} """, - hass, - ).async_render() == ["Hello, Alice!", "Hello, Bob!"] + hass, + ).async_render() + == 30 + ) # Test macro with named arguments - assert template.Template( - """ - {%- macro greet(name, greeting="Hi") -%} + assert ( + template.Template( + """ + {%- macro greet(name, greeting="Hello") -%} {{ greeting }}, {{ name }}! - {%- endmacro %} - {{ ["Alice", "Bob"] | map('apply', greet, greeting="Hello") | list }} + {%- endmacro -%} + {{ apply("World", greet, greeting="Hi") }} """, - hass, - ).async_render() == ["Hello, Alice!", "Hello, Bob!"] + hass, + ).async_render() + == "Hi, World!" + ) - # Test macro with mixed positional and named arguments - assert template.Template( - """ - {%- macro greet(name, separator, greeting="Hi") -%} - {{ greeting }}{{separator}} {{ name }}! - {%- endmacro %} - {{ ["Alice", "Bob"] | map('apply', greet, "," , greeting="Hey") | list }} + # Test macro with mixed arguments + assert ( + template.Template( + """ + {%- macro format_message(prefix, name, suffix="!") -%} + {{ prefix }} {{ name }}{{ suffix }} + {%- endmacro -%} + {{ apply("Welcome", format_message, "John", suffix="...") }} """, - hass, - ).async_render() == ["Hey, Alice!", "Hey, Bob!"] + hass, + ).async_render() + == "Welcome John..." + ) def test_as_function(hass: HomeAssistant) -> None: @@ -862,338 +742,6 @@ def test_as_function_no_arguments(hass: HomeAssistant) -> None: ) -def test_logarithm(hass: HomeAssistant) -> None: - """Test logarithm.""" - tests = [ - (4, 2, 2.0), - (1000, 10, 3.0), - (math.e, "", 1.0), # The "" means the default base (e) will be used - ] - - for value, base, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | log({base}) | round(1) }}}}", hass - ).async_render() - == expected - ) - - assert ( - template.Template( - f"{{{{ log({value}, {base}) | round(1) }}}}", hass - ).async_render() - == expected - ) - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ invalid | log(_) }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ log(invalid, _) }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ 10 | log(invalid) }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ log(10, invalid) }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | log(10, 1) }}") == 1 - assert render(hass, "{{ 'no_number' | log(10, default=1) }}") == 1 - assert render(hass, "{{ log('no_number', 10, 1) }}") == 1 - assert render(hass, "{{ log('no_number', 10, default=1) }}") == 1 - assert render(hass, "{{ log(0, 10, 1) }}") == 1 - assert render(hass, "{{ log(0, 10, default=1) }}") == 1 - - -def test_sine(hass: HomeAssistant) -> None: - """Test sine.""" - tests = [ - (0, 0.0), - (math.pi / 2, 1.0), - (math.pi, 0.0), - (math.pi * 1.5, -1.0), - (math.pi / 10, 0.309), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | sin | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ sin({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'duck' | sin }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | sin('duck') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | sin(1) }}") == 1 - assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1 - assert render(hass, "{{ sin('no_number', 1) }}") == 1 - assert render(hass, "{{ sin('no_number', default=1) }}") == 1 - - -def test_cos(hass: HomeAssistant) -> None: - """Test cosine.""" - tests = [ - (0, 1.0), - (math.pi / 2, 0.0), - (math.pi, -1.0), - (math.pi * 1.5, -0.0), - (math.pi / 10, 0.951), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | cos | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ cos({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'error' | cos }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | cos('error') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | cos(1) }}") == 1 - assert render(hass, "{{ 'no_number' | cos(default=1) }}") == 1 - assert render(hass, "{{ cos('no_number', 1) }}") == 1 - assert render(hass, "{{ cos('no_number', default=1) }}") == 1 - - -def test_tan(hass: HomeAssistant) -> None: - """Test tangent.""" - tests = [ - (0, 0.0), - (math.pi, -0.0), - (math.pi / 180 * 45, 1.0), - (math.pi / 180 * 90, "1.633123935319537e+16"), - (math.pi / 180 * 135, -1.0), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | tan | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ tan({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'error' | tan }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | tan('error') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | tan(1) }}") == 1 - assert render(hass, "{{ 'no_number' | tan(default=1) }}") == 1 - assert render(hass, "{{ tan('no_number', 1) }}") == 1 - assert render(hass, "{{ tan('no_number', default=1) }}") == 1 - - -def test_sqrt(hass: HomeAssistant) -> None: - """Test square root.""" - tests = [ - (0, 0.0), - (1, 1.0), - (2, 1.414), - (10, 3.162), - (100, 10.0), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | sqrt | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ sqrt({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'error' | sqrt }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | sqrt('error') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | sqrt(1) }}") == 1 - assert render(hass, "{{ 'no_number' | sqrt(default=1) }}") == 1 - assert render(hass, "{{ sqrt('no_number', 1) }}") == 1 - assert render(hass, "{{ sqrt('no_number', default=1) }}") == 1 - - -def test_arc_sine(hass: HomeAssistant) -> None: - """Test arcus sine.""" - tests = [ - (-1.0, -1.571), - (-0.5, -0.524), - (0.0, 0.0), - (0.5, 0.524), - (1.0, 1.571), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | asin | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ asin({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - invalid_tests = [ - -2.0, # value error - 2.0, # value error - '"error"', - ] - - for value in invalid_tests: - with pytest.raises(TemplateError): - template.Template( - f"{{{{ {value} | asin | round(3) }}}}", hass - ).async_render() - with pytest.raises(TemplateError): - assert render(hass, f"{{{{ asin({value}) | round(3) }}}}") - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | asin(1) }}") == 1 - assert render(hass, "{{ 'no_number' | asin(default=1) }}") == 1 - assert render(hass, "{{ asin('no_number', 1) }}") == 1 - assert render(hass, "{{ asin('no_number', default=1) }}") == 1 - - -def test_arc_cos(hass: HomeAssistant) -> None: - """Test arcus cosine.""" - tests = [ - (-1.0, 3.142), - (-0.5, 2.094), - (0.0, 1.571), - (0.5, 1.047), - (1.0, 0.0), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | acos | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ acos({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - invalid_tests = [ - -2.0, # value error - 2.0, # value error - '"error"', - ] - - for value in invalid_tests: - with pytest.raises(TemplateError): - template.Template( - f"{{{{ {value} | acos | round(3) }}}}", hass - ).async_render() - with pytest.raises(TemplateError): - assert render(hass, f"{{{{ acos({value}) | round(3) }}}}") - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | acos(1) }}") == 1 - assert render(hass, "{{ 'no_number' | acos(default=1) }}") == 1 - assert render(hass, "{{ acos('no_number', 1) }}") == 1 - assert render(hass, "{{ acos('no_number', default=1) }}") == 1 - - -def test_arc_tan(hass: HomeAssistant) -> None: - """Test arcus tangent.""" - tests = [ - (-10.0, -1.471), - (-2.0, -1.107), - (-1.0, -0.785), - (-0.5, -0.464), - (0.0, 0.0), - (0.5, 0.464), - (1.0, 0.785), - (2.0, 1.107), - (10.0, 1.471), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | atan | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ atan({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'error' | atan }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | atan('error') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | atan(1) }}") == 1 - assert render(hass, "{{ 'no_number' | atan(default=1) }}") == 1 - assert render(hass, "{{ atan('no_number', 1) }}") == 1 - assert render(hass, "{{ atan('no_number', default=1) }}") == 1 - - -def test_arc_tan2(hass: HomeAssistant) -> None: - """Test two parameter version of arcus tangent.""" - tests = [ - (-10.0, -10.0, -2.356), - (-10.0, 0.0, -1.571), - (-10.0, 10.0, -0.785), - (0.0, -10.0, 3.142), - (0.0, 0.0, 0.0), - (0.0, 10.0, 0.0), - (10.0, -10.0, 2.356), - (10.0, 0.0, 1.571), - (10.0, 10.0, 0.785), - (-4.0, 3.0, -0.927), - (-1.0, 2.0, -0.464), - (2.0, 1.0, 1.107), - ] - - for y, x, expected in tests: - assert ( - template.Template( - f"{{{{ ({y}, {x}) | atan2 | round(3) }}}}", hass - ).async_render() - == expected - ) - assert ( - template.Template( - f"{{{{ atan2({y}, {x}) | round(3) }}}}", hass - ).async_render() - == expected - ) - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ ('duck', 'goose') | atan2 }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ atan2('duck', 'goose') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ ('duck', 'goose') | atan2(1) }}") == 1 - assert render(hass, "{{ ('duck', 'goose') | atan2(default=1) }}") == 1 - assert render(hass, "{{ atan2('duck', 'goose', 1) }}") == 1 - assert render(hass, "{{ atan2('duck', 'goose', default=1) }}") == 1 - - def test_strptime(hass: HomeAssistant) -> None: """Test the parse timestamp method.""" tests = [ @@ -1521,211 +1069,6 @@ def test_from_json(hass: HomeAssistant) -> None: assert actual_result == expected_result -def test_average(hass: HomeAssistant) -> None: - """Test the average filter.""" - assert template.Template("{{ [1, 2, 3] | average }}", hass).async_render() == 2 - assert template.Template("{{ average([1, 2, 3]) }}", hass).async_render() == 2 - assert template.Template("{{ average(1, 2, 3) }}", hass).async_render() == 2 - - # Testing of default values - assert template.Template("{{ average([1, 2, 3], -1) }}", hass).async_render() == 2 - assert template.Template("{{ average([], -1) }}", hass).async_render() == -1 - assert template.Template("{{ average([], default=-1) }}", hass).async_render() == -1 - assert ( - template.Template("{{ average([], 5, default=-1) }}", hass).async_render() == -1 - ) - assert ( - template.Template("{{ average(1, 'a', 3, default=-1) }}", hass).async_render() - == -1 - ) - - with pytest.raises(TemplateError): - template.Template("{{ 1 | average }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ average() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ average([]) }}", hass).async_render() - - -def test_median(hass: HomeAssistant) -> None: - """Test the median filter.""" - assert template.Template("{{ [1, 3, 2] | median }}", hass).async_render() == 2 - assert template.Template("{{ median([1, 3, 2, 4]) }}", hass).async_render() == 2.5 - assert template.Template("{{ median(1, 3, 2) }}", hass).async_render() == 2 - assert template.Template("{{ median('cdeba') }}", hass).async_render() == "c" - - # Testing of default values - assert template.Template("{{ median([1, 2, 3], -1) }}", hass).async_render() == 2 - assert template.Template("{{ median([], -1) }}", hass).async_render() == -1 - assert template.Template("{{ median([], default=-1) }}", hass).async_render() == -1 - assert template.Template("{{ median('abcd', -1) }}", hass).async_render() == -1 - assert ( - template.Template("{{ median([], 5, default=-1) }}", hass).async_render() == -1 - ) - assert ( - template.Template("{{ median(1, 'a', 3, default=-1) }}", hass).async_render() - == -1 - ) - - with pytest.raises(TemplateError): - template.Template("{{ 1 | median }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ median() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ median([]) }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ median('abcd') }}", hass).async_render() - - -def test_statistical_mode(hass: HomeAssistant) -> None: - """Test the mode filter.""" - assert ( - template.Template("{{ [1, 2, 2, 3] | statistical_mode }}", hass).async_render() - == 2 - ) - assert ( - template.Template("{{ statistical_mode([1, 2, 3]) }}", hass).async_render() == 1 - ) - assert ( - template.Template( - "{{ statistical_mode('hello', 'bye', 'hello') }}", hass - ).async_render() - == "hello" - ) - assert ( - template.Template("{{ statistical_mode('banana') }}", hass).async_render() - == "a" - ) - - # Testing of default values - assert ( - template.Template("{{ statistical_mode([1, 2, 3], -1) }}", hass).async_render() - == 1 - ) - assert ( - template.Template("{{ statistical_mode([], -1) }}", hass).async_render() == -1 - ) - assert ( - template.Template("{{ statistical_mode([], default=-1) }}", hass).async_render() - == -1 - ) - assert ( - template.Template( - "{{ statistical_mode([], 5, default=-1) }}", hass - ).async_render() - == -1 - ) - - with pytest.raises(TemplateError): - template.Template("{{ 1 | statistical_mode }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ statistical_mode() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ statistical_mode([]) }}", hass).async_render() - - -def test_min(hass: HomeAssistant) -> None: - """Test the min filter.""" - assert template.Template("{{ [1, 2, 3] | min }}", hass).async_render() == 1 - assert template.Template("{{ min([1, 2, 3]) }}", hass).async_render() == 1 - assert template.Template("{{ min(1, 2, 3) }}", hass).async_render() == 1 - - with pytest.raises(TemplateError): - template.Template("{{ 1 | min }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ min() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ min(1) }}", hass).async_render() - - -def test_max(hass: HomeAssistant) -> None: - """Test the max filter.""" - assert template.Template("{{ [1, 2, 3] | max }}", hass).async_render() == 3 - assert template.Template("{{ max([1, 2, 3]) }}", hass).async_render() == 3 - assert template.Template("{{ max(1, 2, 3) }}", hass).async_render() == 3 - - with pytest.raises(TemplateError): - template.Template("{{ 1 | max }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ max() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ max(1) }}", hass).async_render() - - -@pytest.mark.parametrize( - "attribute", - [ - "a", - "b", - "c", - ], -) -def test_min_max_attribute(hass: HomeAssistant, attribute) -> None: - """Test the min and max filters with attribute.""" - hass.states.async_set( - "test.object", - "test", - { - "objects": [ - { - "a": 1, - "b": 2, - "c": 3, - }, - { - "a": 2, - "b": 1, - "c": 2, - }, - { - "a": 3, - "b": 3, - "c": 1, - }, - ], - }, - ) - assert ( - template.Template( - f"{{{{ (state_attr('test.object', 'objects') | min(attribute='{attribute}'))['{attribute}']}}}}", - hass, - ).async_render() - == 1 - ) - assert ( - template.Template( - f"{{{{ (min(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", - hass, - ).async_render() - == 1 - ) - assert ( - template.Template( - f"{{{{ (state_attr('test.object', 'objects') | max(attribute='{attribute}'))['{attribute}']}}}}", - hass, - ).async_render() - == 3 - ) - assert ( - template.Template( - f"{{{{ (max(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", - hass, - ).async_render() - == 3 - ) - - def test_ord(hass: HomeAssistant) -> None: """Test the ord filter.""" assert template.Template('{{ "d" | ord }}', hass).async_render() == 100 @@ -1739,81 +1082,6 @@ def test_from_hex(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - ("value_template", "expected"), - [ - ('{{ "homeassistant" | base64_encode }}', "aG9tZWFzc2lzdGFudA=="), - ("{{ int('0F010003', base=16) | pack('>I') | base64_encode }}", "DwEAAw=="), - ("{{ 'AA01000200150020' | from_hex | base64_encode }}", "qgEAAgAVACA="), - ], -) -def test_base64_encode(hass: HomeAssistant, value_template: str, expected: str) -> None: - """Test the base64_encode filter.""" - assert template.Template(value_template, hass).async_render() == expected - - -def test_base64_decode(hass: HomeAssistant) -> None: - """Test the base64_decode filter.""" - assert ( - template.Template( - '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode }}', hass - ).async_render() - == "homeassistant" - ) - assert ( - template.Template( - '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode(None) }}', hass - ).async_render() - == b"homeassistant" - ) - assert ( - template.Template( - '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode("ascii") }}', hass - ).async_render() - == "homeassistant" - ) - - -def test_slugify(hass: HomeAssistant) -> None: - """Test the slugify filter.""" - assert ( - template.Template('{{ slugify("Home Assistant") }}', hass).async_render() - == "home_assistant" - ) - assert ( - template.Template('{{ "Home Assistant" | slugify }}', hass).async_render() - == "home_assistant" - ) - assert ( - template.Template('{{ slugify("Home Assistant", "-") }}', hass).async_render() - == "home-assistant" - ) - assert ( - template.Template('{{ "Home Assistant" | slugify("-") }}', hass).async_render() - == "home-assistant" - ) - - -def test_ordinal(hass: HomeAssistant) -> None: - """Test the ordinal filter.""" - tests = [ - (1, "1st"), - (2, "2nd"), - (3, "3rd"), - (4, "4th"), - (5, "5th"), - (12, "12th"), - (100, "100th"), - (101, "101st"), - ] - - for value, expected in tests: - assert ( - template.Template(f"{{{{ {value} | ordinal }}}}", hass).async_render() - == expected - ) - - def test_timestamp_utc(hass: HomeAssistant) -> None: """Test the timestamps to local filter.""" now = dt_util.utcnow() @@ -2965,221 +2233,6 @@ def test_version(hass: HomeAssistant) -> None: ).async_render() -def test_regex_match(hass: HomeAssistant) -> None: - """Test regex_match method.""" - tpl = template.Template( - r""" -{{ '123-456-7890' | regex_match('(\\d{3})-(\\d{3})-(\\d{4})') }} - """, - hass, - ) - assert tpl.async_render() is True - - tpl = template.Template( - """ -{{ 'Home Assistant test' | regex_match('home', True) }} - """, - hass, - ) - assert tpl.async_render() is True - - tpl = template.Template( - """ - {{ 'Another Home Assistant test' | regex_match('Home') }} - """, - hass, - ) - assert tpl.async_render() is False - - tpl = template.Template( - """ -{{ ['Home Assistant test'] | regex_match('.*Assist') }} - """, - hass, - ) - assert tpl.async_render() is True - - -def test_match_test(hass: HomeAssistant) -> None: - """Test match test.""" - tpl = template.Template( - r""" -{{ '123-456-7890' is match('(\\d{3})-(\\d{3})-(\\d{4})') }} - """, - hass, - ) - assert tpl.async_render() is True - - -def test_regex_search(hass: HomeAssistant) -> None: - """Test regex_search method.""" - tpl = template.Template( - r""" -{{ '123-456-7890' | regex_search('(\\d{3})-(\\d{3})-(\\d{4})') }} - """, - hass, - ) - assert tpl.async_render() is True - - tpl = template.Template( - """ -{{ 'Home Assistant test' | regex_search('home', True) }} - """, - hass, - ) - assert tpl.async_render() is True - - tpl = template.Template( - """ - {{ 'Another Home Assistant test' | regex_search('Home') }} - """, - hass, - ) - assert tpl.async_render() is True - - tpl = template.Template( - """ -{{ ['Home Assistant test'] | regex_search('Assist') }} - """, - hass, - ) - assert tpl.async_render() is True - - -def test_search_test(hass: HomeAssistant) -> None: - """Test search test.""" - tpl = template.Template( - r""" -{{ '123-456-7890' is search('(\\d{3})-(\\d{3})-(\\d{4})') }} - """, - hass, - ) - assert tpl.async_render() is True - - -def test_regex_replace(hass: HomeAssistant) -> None: - """Test regex_replace method.""" - tpl = template.Template( - r""" -{{ 'Hello World' | regex_replace('(Hello\\s)',) }} - """, - hass, - ) - assert tpl.async_render() == "World" - - tpl = template.Template( - """ -{{ ['Home hinderant test'] | regex_replace('hinder', 'Assist') }} - """, - hass, - ) - assert tpl.async_render() == ["Home Assistant test"] - - -def test_regex_findall(hass: HomeAssistant) -> None: - """Test regex_findall method.""" - tpl = template.Template( - """ -{{ 'Flight from JFK to LHR' | regex_findall('([A-Z]{3})') }} - """, - hass, - ) - assert tpl.async_render() == ["JFK", "LHR"] - - -def test_regex_findall_index(hass: HomeAssistant) -> None: - """Test regex_findall_index method.""" - tpl = template.Template( - """ -{{ 'Flight from JFK to LHR' | regex_findall_index('([A-Z]{3})', 0) }} - """, - hass, - ) - assert tpl.async_render() == "JFK" - - tpl = template.Template( - """ -{{ 'Flight from JFK to LHR' | regex_findall_index('([A-Z]{3})', 1) }} - """, - hass, - ) - assert tpl.async_render() == "LHR" - - tpl = template.Template( - """ -{{ ['JFK', 'LHR'] | regex_findall_index('([A-Z]{3})', 1) }} - """, - hass, - ) - assert tpl.async_render() == "LHR" - - -def test_bitwise_and(hass: HomeAssistant) -> None: - """Test bitwise_and method.""" - tpl = template.Template( - """ -{{ 8 | bitwise_and(8) }} - """, - hass, - ) - assert tpl.async_render() == 8 & 8 - tpl = template.Template( - """ -{{ 10 | bitwise_and(2) }} - """, - hass, - ) - assert tpl.async_render() == 10 & 2 - tpl = template.Template( - """ -{{ 8 | bitwise_and(2) }} - """, - hass, - ) - assert tpl.async_render() == 8 & 2 - - -def test_bitwise_or(hass: HomeAssistant) -> None: - """Test bitwise_or method.""" - tpl = template.Template( - """ -{{ 8 | bitwise_or(8) }} - """, - hass, - ) - assert tpl.async_render() == 8 | 8 - tpl = template.Template( - """ -{{ 10 | bitwise_or(2) }} - """, - hass, - ) - assert tpl.async_render() == 10 | 2 - tpl = template.Template( - """ -{{ 8 | bitwise_or(2) }} - """, - hass, - ) - assert tpl.async_render() == 8 | 2 - - -@pytest.mark.parametrize( - ("value", "xor_value", "expected"), - [(8, 8, 0), (10, 2, 8), (0x8000, 0xFAFA, 31482), (True, False, 1), (True, True, 0)], -) -def test_bitwise_xor( - hass: HomeAssistant, value: Any, xor_value: Any, expected: int -) -> None: - """Test bitwise_xor method.""" - assert ( - template.Template("{{ value | bitwise_xor(xor_value) }}", hass).async_render( - {"value": value, "xor_value": xor_value} - ) - == expected - ) - - def test_pack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test struct pack method.""" @@ -3614,7 +2667,7 @@ async def test_expand(hass: HomeAssistant) -> None: "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() @@ -3641,7 +2694,7 @@ async def test_expand(hass: HomeAssistant) -> None: "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT info = render_to_info( hass, @@ -4698,7 +3751,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non ) assert_result_info(info, ["sensor.a"], {"light.a", "light.b"}, {"sensor"}) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT async def test_async_render_to_info_with_wildcard_matching_entity_id( @@ -4708,7 +3761,7 @@ async def test_async_render_to_info_with_wildcard_matching_entity_id( template_complex_str = r""" {% for state in states.cover %} - {% if state.entity_id | regex_match('.*\\.office_') %} + {% if 'office_' in state.entity_id %} {{ state.entity_id }}={{ state.state }} {% endif %} {% endfor %} @@ -4722,7 +3775,7 @@ async def test_async_render_to_info_with_wildcard_matching_entity_id( assert info.domains == {"cover"} assert info.entities == set() assert info.all_states is False - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT async def test_async_render_to_info_with_wildcard_matching_state( @@ -4732,7 +3785,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( template_complex_str = """ {% for state in states %} - {% if state.state | regex_match('ope.*') %} + {% if state.state.startswith('ope') %} {{ state.entity_id }}={{ state.state }} {% endif %} {% endfor %} @@ -4750,7 +3803,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert not info.domains assert info.entities == set() assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("binary_sensor.door", "off") info = render_to_info(hass, template_complex_str) @@ -4758,12 +3811,12 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert not info.domains assert info.entities == set() assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT template_cover_str = """ {% for state in states.cover %} - {% if state.state | regex_match('ope.*') %} + {% if state.state.startswith('ope') %} {{ state.entity_id }}={{ state.state }} {% endif %} {% endfor %} @@ -4775,7 +3828,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert info.domains == {"cover"} assert info.entities == set() assert info.all_states is False - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT def test_nested_async_render_to_info_case(hass: HomeAssistant) -> None: @@ -5282,20 +4335,6 @@ def test_render_complex_handling_non_template_values(hass: HomeAssistant) -> Non ) == {True: 1, False: 2} -def test_urlencode(hass: HomeAssistant) -> None: - """Test the urlencode method.""" - tpl = template.Template( - "{% set dict = {'foo': 'x&y', 'bar': 42} %}{{ dict | urlencode }}", - hass, - ) - assert tpl.async_render() == "foo=x%26y&bar=42" - tpl = template.Template( - "{% set string = 'the quick brown fox = true' %}{{ string | urlencode }}", - hass, - ) - assert tpl.async_render() == "the%20quick%20brown%20fox%20%3D%20true" - - def test_as_timedelta(hass: HomeAssistant) -> None: """Test the as_timedelta function/filter.""" tpl = template.Template("{{ as_timedelta('PT10M') }}", hass) @@ -6536,51 +5575,6 @@ async def test_template_thread_safety_checks(hass: HomeAssistant) -> None: assert template_obj.async_render_to_info().result() == 23 -@pytest.mark.parametrize( - ("cola", "colb", "expected"), - [ - ([1, 2], [3, 4], [(1, 3), (2, 4)]), - ([1, 2], [3, 4, 5], [(1, 3), (2, 4)]), - ([1, 2, 3, 4], [3, 4], [(1, 3), (2, 4)]), - ], -) -def test_zip(hass: HomeAssistant, cola, colb, expected) -> None: - """Test zip.""" - assert ( - template.Template("{{ zip(cola, colb) | list }}", hass).async_render( - {"cola": cola, "colb": colb} - ) - == expected - ) - assert ( - template.Template( - "[{% for a, b in zip(cola, colb) %}({{a}}, {{b}}), {% endfor %}]", hass - ).async_render({"cola": cola, "colb": colb}) - == expected - ) - - -@pytest.mark.parametrize( - ("col", "expected"), - [ - ([(1, 3), (2, 4)], [(1, 2), (3, 4)]), - (["ax", "by", "cz"], [("a", "b", "c"), ("x", "y", "z")]), - ], -) -def test_unzip(hass: HomeAssistant, col, expected) -> None: - """Test unzipping using zip.""" - assert ( - template.Template("{{ zip(*col) | list }}", hass).async_render({"col": col}) - == expected - ) - assert ( - template.Template( - "{% set a, b = zip(*col) %}[{{a}}, {{b}}]", hass - ).async_render({"col": col}) - == expected - ) - - def test_template_output_exceeds_maximum_size(hass: HomeAssistant) -> None: """Test template output exceeds maximum size.""" tpl = template.Template("{{ 'a' * 1024 * 257 }}", hass) @@ -6881,57 +5875,6 @@ async def test_merge_response_not_mutate_original_object( assert tpl.async_render() -def test_shuffle(hass: HomeAssistant) -> None: - """Test the shuffle function and filter.""" - assert list( - template.Template("{{ [1, 2, 3] | shuffle }}", hass).async_render() - ) == unordered([1, 2, 3]) - - assert list( - template.Template("{{ shuffle([1, 2, 3]) }}", hass).async_render() - ) == unordered([1, 2, 3]) - - assert list( - template.Template("{{ shuffle(1, 2, 3) }}", hass).async_render() - ) == unordered([1, 2, 3]) - - assert list(template.Template("{{ shuffle([]) }}", hass).async_render()) == [] - - assert list(template.Template("{{ [] | shuffle }}", hass).async_render()) == [] - - # Testing using seed - assert list( - template.Template("{{ shuffle([1, 2, 3], 'seed') }}", hass).async_render() - ) == [2, 3, 1] - - assert list( - template.Template( - "{{ shuffle([1, 2, 3], seed='seed') }}", - hass, - ).async_render() - ) == [2, 3, 1] - - assert list( - template.Template( - "{{ [1, 2, 3] | shuffle('seed') }}", - hass, - ).async_render() - ) == [2, 3, 1] - - assert list( - template.Template( - "{{ [1, 2, 3] | shuffle(seed='seed') }}", - hass, - ).async_render() - ) == [2, 3, 1] - - with pytest.raises(TemplateError): - template.Template("{{ 1 | shuffle }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ shuffle() }}", hass).async_render() - - def test_typeof(hass: HomeAssistant) -> None: """Test the typeof debug filter/function.""" assert template.Template("{{ True | typeof }}", hass).async_render() == "bool" @@ -6959,273 +5902,6 @@ def test_typeof(hass: HomeAssistant) -> None: ) -def test_flatten(hass: HomeAssistant) -> None: - """Test the flatten function and filter.""" - assert template.Template( - "{{ flatten([1, [2, [3]], 4, [5 , 6]]) }}", hass - ).async_render() == [1, 2, 3, 4, 5, 6] - - assert template.Template( - "{{ [1, [2, [3]], 4, [5 , 6]] | flatten }}", hass - ).async_render() == [1, 2, 3, 4, 5, 6] - - assert template.Template( - "{{ flatten([1, [2, [3]], 4, [5 , 6]], 1) }}", hass - ).async_render() == [1, 2, [3], 4, 5, 6] - - assert template.Template( - "{{ flatten([1, [2, [3]], 4, [5 , 6]], levels=1) }}", hass - ).async_render() == [1, 2, [3], 4, 5, 6] - - assert template.Template( - "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(1) }}", hass - ).async_render() == [1, 2, [3], 4, 5, 6] - - assert template.Template( - "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(levels=1) }}", hass - ).async_render() == [1, 2, [3], 4, 5, 6] - - assert template.Template("{{ flatten([]) }}", hass).async_render() == [] - - assert template.Template("{{ [] | flatten }}", hass).async_render() == [] - - with pytest.raises(TemplateError): - template.Template("{{ 'string' | flatten }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ flatten() }}", hass).async_render() - - -def test_intersect(hass: HomeAssistant) -> None: - """Test the intersect function and filter.""" - assert list( - template.Template( - "{{ intersect([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == unordered([1, 2, 3, 4, 5]) - - assert list( - template.Template( - "{{ [1, 2, 5, 3, 4, 10] | intersect([1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == unordered([1, 2, 3, 4, 5]) - - assert list( - template.Template( - "{{ intersect(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["b", "c"]) - - assert list( - template.Template( - "{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["b", "c"]) - - assert ( - template.Template("{{ intersect([], [1, 2, 3]) }}", hass).async_render() == [] - ) - - assert ( - template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render() == [] - ) - - with pytest.raises(TemplateError, match="intersect expected a list, got str"): - template.Template("{{ 'string' | intersect([1, 2, 3]) }}", hass).async_render() - - with pytest.raises(TemplateError, match="intersect expected a list, got str"): - template.Template("{{ [1, 2, 3] | intersect('string') }}", hass).async_render() - - -def test_difference(hass: HomeAssistant) -> None: - """Test the difference function and filter.""" - assert list( - template.Template( - "{{ difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == [10] - - assert list( - template.Template( - "{{ [1, 2, 5, 3, 4, 10] | difference([1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == [10] - - assert list( - template.Template( - "{{ difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass - ).async_render() - ) == ["a"] - - assert list( - template.Template( - "{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) }}", hass - ).async_render() - ) == ["a"] - - assert ( - template.Template("{{ difference([], [1, 2, 3]) }}", hass).async_render() == [] - ) - - assert ( - template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render() == [] - ) - - with pytest.raises(TemplateError, match="difference expected a list, got str"): - template.Template("{{ 'string' | difference([1, 2, 3]) }}", hass).async_render() - - with pytest.raises(TemplateError, match="difference expected a list, got str"): - template.Template("{{ [1, 2, 3] | difference('string') }}", hass).async_render() - - -def test_union(hass: HomeAssistant) -> None: - """Test the union function and filter.""" - assert list( - template.Template( - "{{ union([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) - - assert list( - template.Template( - "{{ [1, 2, 5, 3, 4, 10] | union([1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) - - assert list( - template.Template( - "{{ union(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["a", "b", "c", "d"]) - - assert list( - template.Template( - "{{ ['a', 'b', 'c'] | union(['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["a", "b", "c", "d"]) - - assert list( - template.Template("{{ union([], [1, 2, 3]) }}", hass).async_render() - ) == unordered([1, 2, 3]) - - assert list( - template.Template("{{ [] | union([1, 2, 3]) }}", hass).async_render() - ) == unordered([1, 2, 3]) - - with pytest.raises(TemplateError, match="union expected a list, got str"): - template.Template("{{ 'string' | union([1, 2, 3]) }}", hass).async_render() - - with pytest.raises(TemplateError, match="union expected a list, got str"): - template.Template("{{ [1, 2, 3] | union('string') }}", hass).async_render() - - -def test_symmetric_difference(hass: HomeAssistant) -> None: - """Test the symmetric_difference function and filter.""" - assert list( - template.Template( - "{{ symmetric_difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", - hass, - ).async_render() - ) == unordered([10, 11, 99]) - - assert list( - template.Template( - "{{ [1, 2, 5, 3, 4, 10] | symmetric_difference([1, 2, 3, 4, 5, 11, 99]) }}", - hass, - ).async_render() - ) == unordered([10, 11, 99]) - - assert list( - template.Template( - "{{ symmetric_difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["a", "d"]) - - assert list( - template.Template( - "{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["a", "d"]) - - assert list( - template.Template( - "{{ symmetric_difference([], [1, 2, 3]) }}", hass - ).async_render() - ) == unordered([1, 2, 3]) - - assert list( - template.Template( - "{{ [] | symmetric_difference([1, 2, 3]) }}", hass - ).async_render() - ) == unordered([1, 2, 3]) - - with pytest.raises( - TemplateError, match="symmetric_difference expected a list, got str" - ): - template.Template( - "{{ 'string' | symmetric_difference([1, 2, 3]) }}", hass - ).async_render() - - with pytest.raises( - TemplateError, match="symmetric_difference expected a list, got str" - ): - template.Template( - "{{ [1, 2, 3] | symmetric_difference('string') }}", hass - ).async_render() - - -def test_md5(hass: HomeAssistant) -> None: - """Test the md5 function and filter.""" - assert ( - template.Template("{{ md5('Home Assistant') }}", hass).async_render() - == "3d15e5c102c3413d0337393c3287e006" - ) - - assert ( - template.Template("{{ 'Home Assistant' | md5 }}", hass).async_render() - == "3d15e5c102c3413d0337393c3287e006" - ) - - -def test_sha1(hass: HomeAssistant) -> None: - """Test the sha1 function and filter.""" - assert ( - template.Template("{{ sha1('Home Assistant') }}", hass).async_render() - == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" - ) - - assert ( - template.Template("{{ 'Home Assistant' | sha1 }}", hass).async_render() - == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" - ) - - -def test_sha256(hass: HomeAssistant) -> None: - """Test the sha256 function and filter.""" - assert ( - template.Template("{{ sha256('Home Assistant') }}", hass).async_render() - == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" - ) - - assert ( - template.Template("{{ 'Home Assistant' | sha256 }}", hass).async_render() - == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" - ) - - -def test_sha512(hass: HomeAssistant) -> None: - """Test the sha512 function and filter.""" - assert ( - template.Template("{{ sha512('Home Assistant') }}", hass).async_render() - == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" - ) - - assert ( - template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() - == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" - ) - - def test_combine(hass: HomeAssistant) -> None: """Test combine filter and function.""" assert template.Template( diff --git a/tests/helpers/template/test_render_info.py b/tests/helpers/template/test_render_info.py new file mode 100644 index 00000000000..9b746a84610 --- /dev/null +++ b/tests/helpers/template/test_render_info.py @@ -0,0 +1,196 @@ +"""Test template render information tracking for Home Assistant.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +from homeassistant.helpers.template.render_info import ( + ALL_STATES_RATE_LIMIT, + DOMAIN_STATES_RATE_LIMIT, + RenderInfo, + _false, + _true, + render_info_cv, +) + + +def test_render_info_initialization(hass: HomeAssistant) -> None: + """Test RenderInfo initialization.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + assert info.template is template_obj + assert info._result is None + assert info.is_static is False + assert info.exception is None + assert info.all_states is False + assert info.all_states_lifecycle is False + assert info.domains == set() + assert info.domains_lifecycle == set() + assert info.entities == set() + assert info.rate_limit is None + assert info.has_time is False + assert info.filter_lifecycle is _true + assert info.filter is _true + + +def test_render_info_repr(hass: HomeAssistant) -> None: + """Test RenderInfo representation.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + info.domains.add("sensor") + info.entities.add("sensor.test") + + repr_str = repr(info) + assert "RenderInfo" in repr_str + assert "domains={'sensor'}" in repr_str + assert "entities={'sensor.test'}" in repr_str + + +def test_render_info_result(hass: HomeAssistant) -> None: + """Test RenderInfo result property.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Test with no result set - should return None cast as str + assert info.result() is None + + # Test with result set + info._result = "test_result" + assert info.result() == "test_result" + + # Test with exception + info.exception = TemplateError("Test error") + with pytest.raises(TemplateError, match="Test error"): + info.result() + + +def test_render_info_filter_domains_and_entities(hass: HomeAssistant) -> None: + """Test RenderInfo entity and domain filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Add domain and entity + info.domains.add("sensor") + info.entities.add("light.test") + + # Should match domain + assert info._filter_domains_and_entities("sensor.temperature") is True + # Should match entity + assert info._filter_domains_and_entities("light.test") is True + # Should not match + assert info._filter_domains_and_entities("switch.kitchen") is False + + +def test_render_info_filter_entities(hass: HomeAssistant) -> None: + """Test RenderInfo entity-only filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.entities.add("sensor.test") + + assert info._filter_entities("sensor.test") is True + assert info._filter_entities("sensor.other") is False + + +def test_render_info_filter_lifecycle_domains(hass: HomeAssistant) -> None: + """Test RenderInfo domain lifecycle filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.domains_lifecycle.add("sensor") + + assert info._filter_lifecycle_domains("sensor.test") is True + assert info._filter_lifecycle_domains("light.test") is False + + +def test_render_info_freeze_static(hass: HomeAssistant) -> None: + """Test RenderInfo static freezing.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.domains.add("sensor") + info.entities.add("sensor.test") + info.all_states = True + + info._freeze_static() + + assert info.is_static is True + assert info.all_states is False + assert isinstance(info.domains, frozenset) + assert isinstance(info.entities, frozenset) + + +def test_render_info_freeze(hass: HomeAssistant) -> None: + """Test RenderInfo freezing with rate limits.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Test all_states rate limit + info.all_states = True + info._freeze() + assert info.rate_limit == ALL_STATES_RATE_LIMIT + + # Test domain rate limit + info = RenderInfo(template_obj) + info.domains.add("sensor") + info._freeze() + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT + + # Test exception rate limit + info = RenderInfo(template_obj) + info.exception = TemplateError("Test") + info._freeze() + assert info.rate_limit == ALL_STATES_RATE_LIMIT + + +def test_render_info_freeze_filters(hass: HomeAssistant) -> None: + """Test RenderInfo filter assignment during freeze.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + + # Test lifecycle filter assignment + info = RenderInfo(template_obj) + info.domains_lifecycle.add("sensor") + info._freeze() + assert info.filter_lifecycle == info._filter_lifecycle_domains + + # Test no lifecycle domains + info = RenderInfo(template_obj) + info._freeze() + assert info.filter_lifecycle is _false + + # Test domain and entity filter + info = RenderInfo(template_obj) + info.domains.add("sensor") + info._freeze() + assert info.filter == info._filter_domains_and_entities + + # Test entity-only filter + info = RenderInfo(template_obj) + info.entities.add("sensor.test") + info._freeze() + assert info.filter == info._filter_entities + + # Test no domains or entities + info = RenderInfo(template_obj) + info._freeze() + assert info.filter is _false + + +def test_render_info_context_var(hass: HomeAssistant) -> None: + """Test render_info_cv context variable.""" + # Should start as None + assert render_info_cv.get() is None + + # Test setting and getting + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + render_info_cv.set(info) + assert render_info_cv.get() is info + + # Reset for other tests + render_info_cv.set(None) + assert render_info_cv.get() is None diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 3496c41ecf4..54c76334ba7 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -503,18 +503,43 @@ async def test_async_get_areas_by_alias( assert len(area_registry.areas) == 2 - alias1_list = area_registry.async_get_areas_by_alias("A l i a s_1") - alias2_list = area_registry.async_get_areas_by_alias("A l i a s_2") - alias3_list = area_registry.async_get_areas_by_alias("A l i a s_3") + assert area_registry.async_get_areas_by_alias("A l i a s_1") == [area1, area2] + assert area_registry.async_get_areas_by_alias("A l i a s_2") == [area1] + assert area_registry.async_get_areas_by_alias("A l i a s_3") - assert len(alias1_list) == 2 - assert len(alias2_list) == 1 - assert len(alias3_list) == 1 - assert area1 in alias1_list - assert area1 in alias2_list - assert area2 in alias1_list - assert area2 in alias3_list +async def test_async_get_areas_by_alias_collisions( + area_registry: ar.AreaRegistry, +) -> None: + """Make sure we can get the areas by alias when the aliases have collisions.""" + area = area_registry.async_create("Mock1") + assert area_registry.async_get_areas_by_alias("A l i a s 1") == [] + + # Add an alias + updated_area = area_registry.async_update(area.id, aliases={"alias1"}) + assert area_registry.async_get_areas_by_alias("A l i a s 1") == [updated_area] + + # Add a colliding alias + updated_area = area_registry.async_update(area.id, aliases={"alias1", "alias 1"}) + assert area_registry.async_get_areas_by_alias("A l i a s 1") == [updated_area] + + # Add a colliding alias + updated_area = area_registry.async_update( + area.id, aliases={"alias1", "alias 1", "alias 1"} + ) + assert area_registry.async_get_areas_by_alias("A l i a s 1") == [updated_area] + + # Remove a colliding alias + updated_area = area_registry.async_update(area.id, aliases={"alias1", "alias 1"}) + assert area_registry.async_get_areas_by_alias("A l i a s 1") == [updated_area] + + # Remove a colliding alias + updated_area = area_registry.async_update(area.id, aliases={"alias1"}) + assert area_registry.async_get_areas_by_alias("A l i a s 1") == [updated_area] + + # Remove all aliases + updated_area = area_registry.async_update(area.id, aliases={}) + assert area_registry.async_get_areas_by_alias("A l i a s 1") == [] async def test_async_get_area_by_name_not_found(area_registry: ar.AreaRegistry) -> None: diff --git a/tests/helpers/test_automation.py b/tests/helpers/test_automation.py index 1cd9944aecf..6e0a76a28ce 100644 --- a/tests/helpers/test_automation.py +++ b/tests/helpers/test_automation.py @@ -1,10 +1,12 @@ """Test automation helpers.""" import pytest +import voluptuous as vol from homeassistant.helpers.automation import ( get_absolute_description_key, get_relative_description_key, + move_top_level_schema_fields_to_options, ) @@ -34,3 +36,73 @@ def test_relative_description_key(relative_key: str, absolute_key: str) -> None: """Test relative description key.""" DOMAIN = "homeassistant" assert get_relative_description_key(DOMAIN, absolute_key) == relative_key + + +@pytest.mark.parametrize( + ("config", "schema_dict", "expected_config"), + [ + ( + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + }, + {}, + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + "options": {}, + }, + ), + ( + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + }, + { + vol.Required("entity"): str, + vol.Optional("from"): str, + vol.Optional("to"): str, + vol.Optional("for"): dict, + vol.Optional("attribute"): str, + vol.Optional("value_template"): str, + }, + { + "platform": "test", + "extra_field": "extra_value", + "options": { + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + }, + }, + ), + ], +) +async def test_move_schema_fields_to_options( + config, schema_dict, expected_config +) -> None: + """Test moving schema fields to options.""" + assert ( + move_top_level_schema_fields_to_options(config, schema_dict) == expected_config + ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index b037d6a450e..e8e334d2ab6 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -32,6 +32,13 @@ from homeassistant.helpers import ( entity_registry as er, trace, ) +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options +from homeassistant.helpers.condition import ( + Condition, + ConditionCheckerType, + ConditionConfig, + async_validate_condition_config, +) from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration @@ -82,11 +89,26 @@ def assert_condition_trace(expected): assert_element(condition_trace[key][index], element, path) -async def test_invalid_condition(hass: HomeAssistant) -> None: - """Test if invalid condition raises.""" - with pytest.raises(HomeAssistantError): - await condition.async_from_config( - hass, +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"condition": 123}, + "Unexpected value for condition: '123'. Expected a condition, " + "a list of conditions or a valid template", + ) + ], +) +async def test_invalid_condition(hass: HomeAssistant, config: dict, error: str) -> None: + """Test if validating an invalid condition raises.""" + with pytest.raises(vol.Invalid, match=error): + cv.CONDITION_SCHEMA(config) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( { "condition": "invalid", "conditions": [ @@ -97,7 +119,15 @@ async def test_invalid_condition(hass: HomeAssistant) -> None: }, ], }, + 'Invalid condition "invalid" specified', ) + ], +) +async def test_unknown_condition(hass: HomeAssistant, config: dict, error: str) -> None: + """Test if creating an unknown condition raises.""" + config = cv.CONDITION_SCHEMA(config) + with pytest.raises(HomeAssistantError, match=error): + await condition.async_from_config(hass, config) async def test_and_condition(hass: HomeAssistant) -> None: @@ -2082,12 +2112,9 @@ async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: """Test a condition platform with multiple conditions.""" - class MockCondition(condition.Condition): + class MockCondition(Condition): """Mock condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType @@ -2095,23 +2122,24 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: """Validate config.""" return config + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + class MockCondition1(MockCondition): """Mock condition 1.""" - async def async_get_checker(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: True class MockCondition2(MockCondition): """Mock condition 2.""" - async def async_get_checker(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: False - async def async_get_conditions( - hass: HomeAssistant, - ) -> dict[str, type[condition.Condition]]: + async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: return { "_": MockCondition1, "cond_2": MockCondition2, @@ -2125,12 +2153,12 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: config_1 = {CONF_CONDITION: "test"} config_2 = {CONF_CONDITION: "test.cond_2"} config_3 = {CONF_CONDITION: "test.unknown_cond"} - assert await condition.async_validate_condition_config(hass, config_1) == config_1 - assert await condition.async_validate_condition_config(hass, config_2) == config_2 + assert await async_validate_condition_config(hass, config_1) == config_1 + assert await async_validate_condition_config(hass, config_2) == config_2 with pytest.raises( vol.Invalid, match="Invalid condition 'test.unknown_cond' specified" ): - await condition.async_validate_condition_config(hass, config_3) + await async_validate_condition_config(hass, config_3) cond_func = await condition.async_from_config(hass, config_1) assert cond_func(hass, {}) is True @@ -2142,6 +2170,74 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: await condition.async_from_config(hass, config_3) +async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: + """Test a condition platform with a migration.""" + + OPTIONS_SCHEMA_DICT = { + vol.Required("option_1"): str, + vol.Optional("option_2"): int, + } + + class MockCondition(Condition): + """Mock condition.""" + + @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 config + + async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + return { + "_": MockCondition, + } + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + config_1 = { + "condition": "test", + "option_1": "value_1", + "option_2": 2, + } + config_2 = { + "condition": "test", + "option_1": "value_1", + } + config_1_migrated = { + "condition": "test", + "options": {"option_1": "value_1", "option_2": 2}, + } + config_2_migrated = { + "condition": "test", + "options": {"option_1": "value_1"}, + } + + assert await async_validate_condition_config(hass, config_1) == config_1_migrated + assert await async_validate_condition_config(hass, config_2) == config_2_migrated + assert ( + await async_validate_condition_config(hass, config_1_migrated) + == config_1_migrated + ) + assert ( + await async_validate_condition_config(hass, config_2_migrated) + == config_2_migrated + ) + + @pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) async def test_enabled_condition( hass: HomeAssistant, enabled_value: bool | str @@ -2385,7 +2481,15 @@ async def test_async_get_all_descriptions( ) -> None: """Test async_get_all_descriptions.""" device_automation_condition_descriptions = """ - _device: {} + _device: + fields: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME """ assert await async_setup_component(hass, DOMAIN_SUN, {}) @@ -2427,14 +2531,28 @@ async def test_async_get_all_descriptions( "fields": { "after": { "example": "sunrise", - "selector": {"select": {"options": ["sunrise", "sunset"]}}, + "selector": { + "select": { + "custom_value": False, + "multiple": False, + "options": ["sunrise", "sunset"], + "sort": False, + } + }, }, - "after_offset": {"selector": {"time": None}}, + "after_offset": {"selector": {"time": {}}}, "before": { "example": "sunrise", - "selector": {"select": {"options": ["sunrise", "sunset"]}}, + "selector": { + "select": { + "custom_value": False, + "multiple": False, + "options": ["sunrise", "sunset"], + "sort": False, + } + }, }, - "before_offset": {"selector": {"time": None}}, + "before_offset": {"selector": {"time": {}}}, } } } @@ -2456,21 +2574,50 @@ async def test_async_get_all_descriptions( new_descriptions = await condition.async_get_all_descriptions(hass) assert new_descriptions is not descriptions assert new_descriptions == { - "device": { - "fields": {}, - }, "sun": { "fields": { "after": { "example": "sunrise", - "selector": {"select": {"options": ["sunrise", "sunset"]}}, + "selector": { + "select": { + "custom_value": False, + "multiple": False, + "options": ["sunrise", "sunset"], + "sort": False, + } + }, }, - "after_offset": {"selector": {"time": None}}, + "after_offset": {"selector": {"time": {}}}, "before": { "example": "sunrise", - "selector": {"select": {"options": ["sunrise", "sunset"]}}, + "selector": { + "select": { + "custom_value": False, + "multiple": False, + "options": ["sunrise", "sunset"], + "sort": False, + } + }, + }, + "before_offset": {"selector": {"time": {}}}, + } + }, + "device": { + "fields": { + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + "reorder": False, + }, + }, }, - "before_offset": {"selector": {"time": None}}, } }, } diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index aec687be40a..0630c584989 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -711,7 +711,10 @@ async def test_template_no_hass(hass: HomeAssistant) -> None: "{{ no_such_function('group.foo')|map(attribute='entity_id')|list }}", ) for value in options: - await hass.async_add_executor_job(schema, value) + with pytest.raises( + vol.Invalid, match="Validates schema outside the event loop" + ): + await hass.async_add_executor_job(schema, value) def test_dynamic_template(hass: HomeAssistant) -> None: @@ -1455,6 +1458,56 @@ def test_key_value_schemas_with_default() -> None: schema({"mode": "{{ 1 + 1}}"}) +@pytest.mark.usefixtures("hass") +def test_key_value_schemas_with_default_no_list_alternatives() -> None: + """Test key value schemas.""" + schema = vol.Schema( + cv.key_value_schemas( + "mode", + { + "number": vol.Schema({"mode": "number", "data": int}), + "string": vol.Schema({"mode": "string", "data": str}), + }, + vol.Schema({"mode": cv.dynamic_template}), + "a cool template", + list_alternatives=False, + ) + ) + + with pytest.raises(vol.Invalid) as excinfo: + schema(True) + assert str(excinfo.value) == "Expected a dictionary" + + for mode in None, {"a": "dict"}, "invalid": + with pytest.raises(vol.Invalid) as excinfo: + schema({"mode": mode}) + assert ( + str(excinfo.value) + == f"Unexpected value for mode: '{mode}'. Expected a cool template" + ) + + +@pytest.mark.usefixtures("hass") +def test_key_value_schemas_without_default_no_list_alternatives() -> None: + """Test key value schemas.""" + with pytest.raises(ValueError) as excinfo: + vol.Schema( + cv.key_value_schemas( + "mode", + { + "number": vol.Schema({"mode": "number", "data": int}), + "string": vol.Schema({"mode": "string", "data": str}), + }, + vol.Schema({"mode": cv.dynamic_template}), + list_alternatives=False, + ) + ) + assert ( + str(excinfo.value) + == "default_description must be provided if list_alternatives is False" + ) + + @pytest.mark.parametrize( ("config", "error"), [ @@ -1462,6 +1515,11 @@ def test_key_value_schemas_with_default() -> None: ({"wait_template": "{{ invalid"}, "invalid template"), # The validation error message could be improved to explain that this is not # a valid shorthand template + ( + {"condition": 123}, + "Unexpected value for condition: '123'. Expected a condition, a list of " + "conditions or a valid template", + ), ( {"condition": "not", "conditions": "not a dynamic template"}, "Expected a dictionary", diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index b2dd8943e78..55c03aa630a 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -61,12 +61,12 @@ async def test_immediate_works(hass: HomeAssistant) -> None: assert debouncer._job.target == debouncer.function assert debouncer._job == before_job - # Test calling doesn't execute/cooldown if currently executing. + # Test calling enabled timer if currently executing. await debouncer._execute_lock.acquire() await debouncer.async_call() assert len(calls) == 2 - assert debouncer._timer_task is None - assert debouncer._execute_at_end_of_timer is False + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function @@ -118,13 +118,13 @@ async def test_immediate_works_with_schedule_call(hass: HomeAssistant) -> None: assert debouncer._job.target == debouncer.function assert debouncer._job == before_job - # Test calling doesn't execute/cooldown if currently executing. + # Test calling enabled timer if currently executing. await debouncer._execute_lock.acquire() debouncer.async_schedule_call() await hass.async_block_till_done() assert len(calls) == 2 - assert debouncer._timer_task is None - assert debouncer._execute_at_end_of_timer is False + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function @@ -225,12 +225,12 @@ async def test_immediate_works_with_passed_callback_function_raises( assert debouncer._job.target == debouncer.function assert debouncer._job == before_job - # Test calling doesn't execute/cooldown if currently executing. + # Test calling enabled timer if currently executing. await debouncer._execute_lock.acquire() await debouncer.async_call() assert len(calls) == 2 - assert debouncer._timer_task is None - assert debouncer._execute_at_end_of_timer is False + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function @@ -288,12 +288,12 @@ async def test_immediate_works_with_passed_coroutine_raises( assert debouncer._job.target == debouncer.function assert debouncer._job == before_job - # Test calling doesn't execute/cooldown if currently executing. + # Test calling enabled timer if currently executing. await debouncer._execute_lock.acquire() await debouncer.async_call() assert len(calls) == 2 - assert debouncer._timer_task is None - assert debouncer._execute_at_end_of_timer is False + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function @@ -339,12 +339,12 @@ async def test_not_immediate_works(hass: HomeAssistant) -> None: # Reset debouncer debouncer.async_cancel() - # Test calling doesn't schedule if currently executing. + # Test calling enabled timer if currently executing. await debouncer._execute_lock.acquire() await debouncer.async_call() assert len(calls) == 1 - assert debouncer._timer_task is None - assert debouncer._execute_at_end_of_timer is False + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function @@ -393,13 +393,13 @@ async def test_not_immediate_works_schedule_call(hass: HomeAssistant) -> None: # Reset debouncer debouncer.async_cancel() - # Test calling doesn't schedule if currently executing. + # Test calling enabled timer if currently executing. await debouncer._execute_lock.acquire() debouncer.async_schedule_call() await hass.async_block_till_done() assert len(calls) == 1 - assert debouncer._timer_task is None - assert debouncer._execute_at_end_of_timer is False + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function @@ -455,13 +455,13 @@ async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> Non assert debouncer._job.target == debouncer.function assert debouncer._job != before_job - # Test calling doesn't execute/cooldown if currently executing. + # Test calling enabled timer if currently executing. await debouncer._execute_lock.acquire() await debouncer.async_call() assert len(calls) == 2 assert calls == [1, 2] - assert debouncer._timer_task is None - assert debouncer._execute_at_end_of_timer is False + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index d45c9ce1546..b77e7e1ef44 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -17,6 +17,7 @@ from homeassistant.helpers.deprecation import ( check_if_deprecated_constant, deprecated_class, deprecated_function, + deprecated_hass_argument, deprecated_substitute, dir_with_deprecated_constants, get_deprecated, @@ -638,3 +639,77 @@ def test_enum_with_deprecated_members_integration_not_found( TestEnum.DOGS # noqa: B018 assert len(caplog.record_tuples) == 0 + + +@pytest.mark.parametrize( + ("positional_arguments", "keyword_arguments"), + [ + # without kwargs + ([], {}), + (["first_arg"], {}), + (["first_arg", "second_arg"], {}), + # with single kwargs + ([], {"first_kwarg": "first_value"}), + (["first_arg"], {"first_kwarg": "first_value"}), + (["first_arg", "second_arg"], {"first_kwarg": "first_value"}), + # with double kwargs + ([], {"first_kwarg": "first_value", "second_kwarg": "second_value"}), + (["first_arg"], {"first_kwarg": "first_value", "second_kwarg": "second_value"}), + ( + ["first_arg", "second_arg"], + {"first_kwarg": "first_value", "second_kwarg": "second_value"}, + ), + ], +) +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " It will be removed in HA Core 2099.1."), + ], +) +def test_deprecated_hass_argument( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + positional_arguments: list[str], + keyword_arguments: dict[str, str], + breaks_in_ha_version: str | None, + extra_msg: str, +) -> None: + """Test deprecated_hass_argument decorator.""" + + calls = [] + + @deprecated_hass_argument(breaks_in_ha_version=breaks_in_ha_version) + def mock_deprecated_function(*args: str, **kwargs: str) -> None: + calls.append((args, kwargs)) + + mock_deprecated_function(*positional_arguments, **keyword_arguments) + assert ( + "The deprecated argument hass was passed to mock_deprecated_function." + f"{extra_msg}" + " Use mock_deprecated_function without hass argument instead" + ) not in caplog.text + assert len(calls) == 1 + + mock_deprecated_function(hass, *positional_arguments, **keyword_arguments) + assert ( + "The deprecated argument hass was passed to mock_deprecated_function." + f"{extra_msg}" + " Use mock_deprecated_function without hass argument instead" + ) in caplog.text + assert len(calls) == 2 + + caplog.clear() + mock_deprecated_function(*positional_arguments, hass=hass, **keyword_arguments) + assert ( + "The deprecated argument hass was passed to mock_deprecated_function." + f"{extra_msg}" + " Use mock_deprecated_function without hass argument instead" + ) in caplog.text + assert len(calls) == 3 + + # Ensure that the two calls are the same, as the second call should have been + # modified to remove the hass argument. + assert calls[0] == calls[1] + assert calls[0] == calls[2] diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index d45c4f6cf91..a3490da9514 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -8,6 +8,7 @@ import time from typing import Any from unittest.mock import ANY, patch +import attr from freezegun.api import FrozenDateTimeFactory import pytest from yarl import URL @@ -21,6 +22,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_capture_events, flush_store @@ -349,6 +351,7 @@ async def test_loading_from_storage( "connections": [["Zigbee", "23.45.67.89.01"]], "created_at": created_at, "disabled_by": dr.DeviceEntryDisabler.USER, + "disabled_by_undefined": False, "id": "bcdefghijklmn", "identifiers": [["serial", "3456ABCDEF12"]], "labels": {"label1", "label2"}, @@ -508,6 +511,9 @@ async def test_migration_from_1_1( ) assert entry.id == "abcdefghijklm" + deleted_entry = registry.deleted_devices["deletedid"] + assert deleted_entry.disabled_by is UNDEFINED + # Update to trigger a store entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, @@ -582,6 +588,7 @@ async def test_migration_from_1_1( "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, + "disabled_by_undefined": True, "id": "deletedid", "identifiers": [["serial", "123456ABCDFF"]], "labels": [], @@ -1477,6 +1484,7 @@ async def test_migration_from_1_10( "connections": [["mac", "123456ABCDAB"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, + "disabled_by_undefined": False, "id": "abcdefghijklm2", "identifiers": [["serial", "123456ABCDAB"]], "labels": [], @@ -1553,6 +1561,143 @@ async def test_migration_from_1_10( "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, + "disabled_by_undefined": False, + "id": "abcdefghijklm2", + "identifiers": [["serial", "123456ABCDAB"]], + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": "1970-01-01T00:00:00+00:00", + }, + ], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_from_1_11( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from version 1.11.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [["mac", "123456ABCDEF"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": ["blah"], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "area_id": None, + "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, + "connections": [["mac", "123456ABCDAB"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "id": "abcdefghijklm2", + "identifiers": [["serial", "123456ABCDAB"]], + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": "1970-01-01T00:00:00+00:00", + }, + ], + }, + } + + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Test data was loaded + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + ) + assert entry.id == "abcdefghijklm" + deleted_entry = registry.deleted_devices.get_entry( + connections=set(), + identifiers={("serial", "123456ABCDAB")}, + ) + assert deleted_entry.id == "abcdefghijklm2" + + # Update to trigger a store + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + sw_version="new_version", + ) + assert entry.id == "abcdefghijklm" + + # Check we store migrated data + await flush_store(registry._store) + + assert hass_storage[dr.STORAGE_KEY] == { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": ["blah"], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "area_id": None, + "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, + "connections": [["mac", "12:34:56:ab:cd:ab"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": False, "id": "abcdefghijklm2", "identifiers": [["serial", "123456ABCDAB"]], "labels": [], @@ -3279,6 +3424,266 @@ async def test_update_suggested_area( assert updated_entry.area_id == device_area_id +@pytest.mark.parametrize( + ( + "new_config_entry_disabled_by", + "device_disabled_by_initial", + "device_disabled_by_updated", + "extra_changes", + ), + [ + ( + None, + None, + None, + {}, + ), + # Config entry not disabled, device was disabled by config entry. + # Device not disabled when updated. + ( + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + None, + {"disabled_by": dr.DeviceEntryDisabler.CONFIG_ENTRY}, + ), + ( + None, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + {}, + ), + ( + None, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + None, + None, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + {}, + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_add_config_entry_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + new_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, + device_disabled_by_initial: dr.DeviceEntryDisabler | None, + device_disabled_by_updated: dr.DeviceEntryDisabler | None, + extra_changes: dict[str, Any], +) -> None: + """Check how the disabled_by flag is treated when adding a config entry.""" + config_entry_1 = MockConfigEntry(title=None) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + title=None, disabled_by=new_config_entry_disabled_by + ) + config_entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id=None, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by_initial, + ) + assert entry.disabled_by == device_disabled_by_initial + + entry2 = device_registry.async_update_device( + entry.id, add_config_entry_id=config_entry_2.entry_id + ) + + assert entry2 == dr.DeviceEntry( + config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, + config_entries_subentries={ + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + }, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=device_disabled_by_updated, + id=entry.id, + modified_at=utcnow(), + primary_config_entry=None, + ) + + await hass.async_block_till_done() + + assert len(update_events) == 2 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, + } + | extra_changes, + } + + +@pytest.mark.parametrize( + ( + "removed_config_entry_disabled_by", + "device_disabled_by_initial", + "device_disabled_by_updated", + "extra_changes", + ), + [ + # The non-disabled config entry is removed, device changed to + # disabled by config entry. + ( + None, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {"disabled_by": None}, + ), + ( + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {}, + ), + ( + None, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + {}, + ), + ( + None, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + {}, + ), + # In this test, the device is in an invalid state: config entry disabled, + # device not disabled. After removing the config entry, the device is disabled + # by checking the remaining config entry. + ( + config_entries.ConfigEntryDisabler.USER, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {"disabled_by": None}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + {}, + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_remove_config_entry_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + removed_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, + device_disabled_by_initial: dr.DeviceEntryDisabler | None, + device_disabled_by_updated: dr.DeviceEntryDisabler | None, + extra_changes: dict[str, Any], +) -> None: + """Check how the disabled_by flag is treated when removing a config entry.""" + config_entry_1 = MockConfigEntry( + title=None, disabled_by=removed_config_entry_disabled_by + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + title=None, disabled_by=config_entries.ConfigEntryDisabler.USER + ) + config_entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id=None, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by_initial, + ) + assert entry.disabled_by == device_disabled_by_initial + + entry2 = device_registry.async_update_device( + entry.id, add_config_entry_id=config_entry_2.entry_id + ) + assert entry2.disabled_by == device_disabled_by_initial + + entry3 = device_registry.async_update_device( + entry.id, remove_config_entry_id=config_entry_1.entry_id + ) + + assert entry3 == dr.DeviceEntry( + config_entries={config_entry_2.entry_id}, + config_entries_subentries={config_entry_2.entry_id: {None}}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=device_disabled_by_updated, + id=entry.id, + modified_at=utcnow(), + primary_config_entry=None, + ) + + await hass.async_block_till_done() + + assert len(update_events) == 3 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, + }, + } + assert update_events[2].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + }, + } + | extra_changes, + } + + async def test_cleanup_device_registry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -3368,98 +3773,6 @@ async def test_cleanup_startup(hass: HomeAssistant) -> None: assert len(mock_call.mock_calls) == 1 -async def test_deleted_device_clears_disabled_by_on_config_entry_removal( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, -) -> None: - """Test that disabled_by is cleared when config entry is removed.""" - config_entry = MockConfigEntry(domain="test", entry_id="mock-id-1") - config_entry.add_to_hass(hass) - - # Create a device disabled by the config entry - device = device_registry.async_get_or_create( - config_entry_id="mock-id-1", - identifiers={("test", "device_1")}, - name="Test Device", - disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, - ) - assert device.config_entries == {"mock-id-1"} - assert device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY - - # Remove the device (it moves to deleted_devices) - device_registry.async_remove_device(device.id) - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - deleted_device = device_registry.deleted_devices[device.id] - assert deleted_device.config_entries == {"mock-id-1"} - assert deleted_device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY - assert deleted_device.orphaned_timestamp is None - - # Clear the config entry - device_registry.async_clear_config_entry("mock-id-1") - - # Verify disabled_by is cleared - deleted_device = device_registry.deleted_devices[device.id] - assert deleted_device.config_entries == set() - assert deleted_device.disabled_by is None # Should be cleared - assert deleted_device.orphaned_timestamp is not None - - # Now re-add the config entry and device to verify it can be enabled - config_entry2 = MockConfigEntry(domain="test", entry_id="mock-id-2") - config_entry2.add_to_hass(hass) - - # Re-create the device with same identifiers - device2 = device_registry.async_get_or_create( - config_entry_id="mock-id-2", - identifiers={("test", "device_1")}, - name="Test Device", - ) - assert device2.config_entries == {"mock-id-2"} - assert device2.disabled_by is None # Should not be disabled anymore - assert device2.id == device.id # Should keep the same device id - - -async def test_deleted_device_disabled_by_user_not_cleared( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, -) -> None: - """Test that disabled_by=USER is not cleared when config entry is removed.""" - config_entry = MockConfigEntry(domain="test", entry_id="mock-id-1") - config_entry.add_to_hass(hass) - - # Create a device disabled by the user - device = device_registry.async_get_or_create( - config_entry_id="mock-id-1", - identifiers={("test", "device_1")}, - name="Test Device", - disabled_by=dr.DeviceEntryDisabler.USER, - ) - assert device.config_entries == {"mock-id-1"} - assert device.disabled_by is dr.DeviceEntryDisabler.USER - - # Remove the device (it moves to deleted_devices) - device_registry.async_remove_device(device.id) - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - deleted_device = device_registry.deleted_devices[device.id] - assert deleted_device.config_entries == {"mock-id-1"} - assert deleted_device.disabled_by is dr.DeviceEntryDisabler.USER - assert deleted_device.orphaned_timestamp is None - - # Clear the config entry - device_registry.async_clear_config_entry("mock-id-1") - - # Verify disabled_by is NOT cleared for USER disabled devices - deleted_device = device_registry.deleted_devices[device.id] - assert deleted_device.config_entries == set() - assert ( - deleted_device.disabled_by is dr.DeviceEntryDisabler.USER - ) # Should remain USER - assert deleted_device.orphaned_timestamp is not None - - @pytest.mark.parametrize("load_registries", [False]) async def test_cleanup_entity_registry_change( hass: HomeAssistant, mock_config_entry: MockConfigEntry @@ -3665,6 +3978,298 @@ async def test_restore_device( } +@pytest.mark.parametrize( + ("device_disabled_by", "expected_disabled_by"), + [ + (None, None), + (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), + (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), + (dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER), + (UNDEFINED, None), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_device_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring a device.""" + entry_id = mock_config_entry.entry_id + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_orig.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=None, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_orig", + model="model_orig", + model_id="model_id_orig", + name="name_orig", + serial_number="serial_no_orig", + suggested_area="suggested_area_orig", + sw_version="version_orig", + via_device="via_device_id_orig", + ) + + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(entry.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + + deleted_entry = device_registry.deleted_devices[entry.id] + device_registry.deleted_devices[entry.id] = attr.evolve( + deleted_entry, disabled_by=UNDEFINED + ) + + # This will restore the original device, user customizations of + # area_id, disabled_by, labels and name_by_user will be restored + entry3 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by, + entry_type=None, + hw_version="hw_version_new", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + via_device="via_device_id_new", + ) + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_orig", + config_entries={entry_id}, + config_entries_subentries={entry_id: {None}}, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=expected_disabled_by, + entry_type=None, + hw_version="hw_version_new", + id=entry.id, + identifiers={("bridgeid", "0123")}, + labels=set(), + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + modified_at=utcnow(), + name_by_user=None, + name="name_new", + primary_config_entry=entry_id, + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + ) + + assert entry.id == entry3.id + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + assert isinstance(entry3.config_entries, set) + assert isinstance(entry3.connections, set) + assert isinstance(entry3.identifiers, set) + + await hass.async_block_till_done() + + assert len(update_events) == 3 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "remove", + "device_id": entry.id, + "device": entry.dict_repr, + } + assert update_events[2].data == { + "action": "create", + "device_id": entry3.id, + } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by_initial", + "device_disabled_by_restored", + ), + [ + ( + None, + None, + None, + ), + # Config entry not disabled, device was disabled by config entry. + # Device not disabled when restored. + ( + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + None, + ), + ( + None, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + ), + ( + None, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + ), + # Config entry disabled, device not disabled. + # Device disabled by config entry when restored. + ( + config_entries.ConfigEntryDisabler.USER, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, + device_disabled_by_initial: dr.DeviceEntryDisabler | None, + device_disabled_by_restored: dr.DeviceEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring a device.""" + entry_id = mock_config_entry.entry_id + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + await hass.config_entries.async_set_disabled_by( + mock_config_entry.entry_id, config_entry_disabled_by + ) + entry = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_orig.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by_initial, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_orig", + model="model_orig", + model_id="model_id_orig", + name="name_orig", + serial_number="serial_no_orig", + suggested_area="suggested_area_orig", + sw_version="version_orig", + via_device="via_device_id_orig", + ) + + assert entry.disabled_by == device_disabled_by_initial + + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(entry.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + + # This will restore the original device, user customizations of + # area_id, disabled_by, labels and name_by_user will be restored + entry3 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=None, + entry_type=None, + hw_version="hw_version_new", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + via_device="via_device_id_new", + ) + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_orig", + config_entries={entry_id}, + config_entries_subentries={entry_id: {None}}, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=device_disabled_by_restored, + entry_type=None, + hw_version="hw_version_new", + id=entry.id, + identifiers={("bridgeid", "0123")}, + labels=set(), + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + modified_at=utcnow(), + name_by_user=None, + name="name_new", + primary_config_entry=entry_id, + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + ) + + assert entry.id == entry3.id + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + assert isinstance(entry3.config_entries, set) + assert isinstance(entry3.connections, set) + assert isinstance(entry3.identifiers, set) + + await hass.async_block_till_done() + + assert len(update_events) == 3 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "remove", + "device_id": entry.id, + "device": entry.dict_repr, + } + assert update_events[2].data == { + "action": "create", + "device_id": entry3.id, + } + + @pytest.mark.usefixtures("freezer") async def test_restore_shared_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry @@ -4678,7 +5283,7 @@ async def test_async_get_or_create_thread_safety( with pytest.raises( RuntimeError, - match="Detected code that calls device_registry.async_update_device from a thread.", + match="Detected code that calls device_registry._async_update_device from a thread.", ): await hass.async_add_executor_job( partial( diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 20c243d0701..dc24e715620 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -560,11 +560,10 @@ async def test_register_entity_service( async def test_register_entity_service_non_entity_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test attempting to register a service with a non entity service schema.""" component = EntityComponent(_LOGGER, DOMAIN, hass) - expected_message = "registers an entity service with a non entity service schema" for idx, schema in enumerate( ( @@ -573,9 +572,12 @@ async def test_register_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - component.async_register_entity_service(f"hello_{idx}", schema, Mock()) - assert expected_message in caplog.text - caplog.clear() + expected_message = ( + f"The test_domain.hello_{idx} service registers " + "an entity service with a non entity service schema" + ) + with pytest.raises(HomeAssistantError, match=expected_message): + component.async_register_entity_service(f"hello_{idx}", schema, Mock()) for idx, schema in enumerate( ( @@ -585,7 +587,6 @@ async def test_register_entity_service_non_entity_service_schema( ) ): component.async_register_entity_service(f"test_service_{idx}", schema, Mock()) - assert expected_message not in caplog.text async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: @@ -693,40 +694,6 @@ async def test_register_entity_service_response_data_multiple_matches_raises( ) -async def test_legacy_register_entity_service_response_data_multiple_matches( - hass: HomeAssistant, -) -> None: - """Test asking for legacy service response data but matching many entities.""" - entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") - entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") - - async def generate_response( - target: MockEntity, call: ServiceCall - ) -> ServiceResponse: - return {"response-key": "response-value"} - - component = EntityComponent(_LOGGER, DOMAIN, hass) - await component.async_setup({}) - await component.async_add_entities([entity1, entity2]) - - component.async_register_legacy_entity_service( - "hello", - {"some": str}, - generate_response, - supports_response=SupportsResponse.ONLY, - ) - - with pytest.raises(HomeAssistantError, match="matched more than one entity"): - await hass.services.async_call( - DOMAIN, - "hello", - service_data={"some": "data"}, - target={"entity_id": [entity1.entity_id, entity2.entity_id]}, - blocking=True, - return_response=True, - ) - - async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: """Test that we shutdown platforms on stop.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 53331b676fe..9f4b6a83c80 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1878,13 +1878,12 @@ async def test_register_entity_service_none_schema( async def test_register_entity_service_non_entity_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test attempting to register a service with a non entity service schema.""" entity_platform = MockEntityPlatform( hass, domain="mock_integration", platform_name="mock_platform", platform=None ) - expected_message = "registers an entity service with a non entity service schema" for idx, schema in enumerate( ( @@ -1893,9 +1892,14 @@ async def test_register_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - entity_platform.async_register_entity_service(f"hello_{idx}", schema, Mock()) - assert expected_message in caplog.text - caplog.clear() + expected_message = ( + f"The mock_platform.hello_{idx} service registers " + "an entity service with a non entity service schema" + ) + with pytest.raises(HomeAssistantError, match=expected_message): + entity_platform.async_register_entity_service( + f"hello_{idx}", schema, Mock() + ) for idx, schema in enumerate( ( @@ -1907,7 +1911,6 @@ async def test_register_entity_service_non_entity_service_schema( entity_platform.async_register_entity_service( f"test_service_{idx}", schema, Mock() ) - assert expected_message not in caplog.text @pytest.mark.parametrize("update_before_add", [True, False]) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 89822b80039..593e1ea9703 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -20,6 +20,7 @@ from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.dt import utc_from_timestamp, utcnow from tests.common import ( @@ -610,14 +611,17 @@ async def test_load_bad_data( "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "disabled_by": None, + "disabled_by_undefined": False, "entity_id": "test.test3", "hidden_by": None, + "hidden_by_undefined": False, "icon": None, "id": "00003", "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, "options": None, + "options_undefined": False, "orphaned_timestamp": None, "platform": "super_platform", "unique_id": 234, # Should not load @@ -631,14 +635,17 @@ async def test_load_bad_data( "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "disabled_by": None, + "disabled_by_undefined": False, "entity_id": "test.test4", "hidden_by": None, + "hidden_by_undefined": False, "icon": None, "id": "00004", "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, "options": None, + "options_undefined": False, "orphaned_timestamp": None, "platform": "super_platform", "unique_id": ["also", "not", "valid"], # Should not load @@ -782,97 +789,6 @@ async def test_deleted_entity_removing_config_entry_id( assert entity_registry.deleted_entities[("light", "hue", "1234")] == deleted_entry2 -async def test_deleted_entity_clears_disabled_by_on_config_entry_removal( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, -) -> None: - """Test that disabled_by is cleared when config entry is removed.""" - mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") - mock_config.add_to_hass(hass) - - # Create an entity disabled by the config entry - entry = entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=mock_config, - disabled_by=er.RegistryEntryDisabler.CONFIG_ENTRY, - ) - assert entry.config_entry_id == "mock-id-1" - assert entry.disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY - - # Remove the entity (it moves to deleted_entities) - entity_registry.async_remove(entry.entity_id) - - assert len(entity_registry.entities) == 0 - assert len(entity_registry.deleted_entities) == 1 - deleted_entry = entity_registry.deleted_entities[("light", "hue", "5678")] - assert deleted_entry.config_entry_id == "mock-id-1" - assert deleted_entry.disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY - assert deleted_entry.orphaned_timestamp is None - - # Clear the config entry - entity_registry.async_clear_config_entry("mock-id-1") - - # Verify disabled_by is cleared - deleted_entry = entity_registry.deleted_entities[("light", "hue", "5678")] - assert deleted_entry.config_entry_id is None - assert deleted_entry.disabled_by is None # Should be cleared - assert deleted_entry.orphaned_timestamp is not None - - # Now re-add the config entry and entity to verify it can be enabled - mock_config2 = MockConfigEntry(domain="light", entry_id="mock-id-2") - mock_config2.add_to_hass(hass) - - # Re-create the entity with same unique ID - entry2 = entity_registry.async_get_or_create( - "light", "hue", "5678", config_entry=mock_config2 - ) - assert entry2.config_entry_id == "mock-id-2" - assert entry2.disabled_by is None # Should not be disabled anymore - - -async def test_deleted_entity_disabled_by_user_not_cleared( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, -) -> None: - """Test that disabled_by=USER is not cleared when config entry is removed.""" - mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") - mock_config.add_to_hass(hass) - - # Create an entity disabled by the user - entry = entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=mock_config, - disabled_by=er.RegistryEntryDisabler.USER, - ) - assert entry.config_entry_id == "mock-id-1" - assert entry.disabled_by is er.RegistryEntryDisabler.USER - - # Remove the entity (it moves to deleted_entities) - entity_registry.async_remove(entry.entity_id) - - assert len(entity_registry.entities) == 0 - assert len(entity_registry.deleted_entities) == 1 - deleted_entry = entity_registry.deleted_entities[("light", "hue", "5678")] - assert deleted_entry.config_entry_id == "mock-id-1" - assert deleted_entry.disabled_by is er.RegistryEntryDisabler.USER - assert deleted_entry.orphaned_timestamp is None - - # Clear the config entry - entity_registry.async_clear_config_entry("mock-id-1") - - # Verify disabled_by is NOT cleared for USER disabled entities - deleted_entry = entity_registry.deleted_entities[("light", "hue", "5678")] - assert deleted_entry.config_entry_id is None - assert ( - deleted_entry.disabled_by is er.RegistryEntryDisabler.USER - ) # Should remain USER - assert deleted_entry.orphaned_timestamp is not None - - async def test_removing_config_subentry_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -1053,9 +969,10 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) assert entry.device_class is None assert entry.original_device_class == "best_class" - # Check we store migrated data + # Check migrated data await flush_store(registry._store) - assert hass_storage[er.STORAGE_KEY] == { + migrated_data = hass_storage[er.STORAGE_KEY] + assert migrated_data == { "version": er.STORAGE_VERSION_MAJOR, "minor_version": er.STORAGE_VERSION_MINOR, "key": er.STORAGE_KEY, @@ -1098,6 +1015,11 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) }, } + # Serialize the migrated data again + registry.async_schedule_save() + await flush_store(registry._store) + assert hass_storage[er.STORAGE_KEY] == migrated_data + @pytest.mark.parametrize("load_registries", [False]) async def test_migration_1_7(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: @@ -1233,9 +1155,17 @@ async def test_migration_1_11( assert entry.device_class is None assert entry.original_device_class == "best_class" + deleted_entry = registry.deleted_entities[ + ("test", "super_duper_platform", "very_very_unique") + ] + assert deleted_entry.disabled_by is UNDEFINED + assert deleted_entry.hidden_by is UNDEFINED + assert deleted_entry.options is UNDEFINED + # Check migrated data await flush_store(registry._store) - assert hass_storage[er.STORAGE_KEY] == { + migrated_data = hass_storage[er.STORAGE_KEY] + assert migrated_data == { "version": er.STORAGE_VERSION_MAJOR, "minor_version": er.STORAGE_VERSION_MINOR, "key": er.STORAGE_KEY, @@ -1274,6 +1204,87 @@ async def test_migration_1_11( "device_class": None, } ], + "deleted_entities": [ + { + "aliases": [], + "area_id": None, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_class": None, + "disabled_by": None, + "disabled_by_undefined": True, + "entity_id": "test.deleted_entity", + "hidden_by": None, + "hidden_by_undefined": True, + "icon": None, + "id": "23456", + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "options_undefined": True, + "orphaned_timestamp": None, + "platform": "super_duper_platform", + "unique_id": "very_very_unique", + } + ], + }, + } + + # Serialize the migrated data again + registry.async_schedule_save() + await flush_store(registry._store) + assert hass_storage[er.STORAGE_KEY] == migrated_data + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_1_18( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test migration from version 1.18. + + This version has a flawed migration. + """ + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 18, + "data": { + "entities": [ + { + "aliases": [], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": None, + "disabled_by": None, + "entity_category": None, + "entity_id": "test.entity", + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "id": "12345", + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "original_device_class": "best_class", + "original_icon": None, + "original_name": None, + "platform": "super_platform", + "previous_unique_id": None, + "suggested_object_id": None, + "supported_features": 0, + "translation_key": None, + "unique_id": "very_unique", + "unit_of_measurement": None, + "device_class": None, + } + ], "deleted_entities": [ { "aliases": [], @@ -1300,6 +1311,97 @@ async def test_migration_1_11( }, } + await er.async_load(hass) + registry = er.async_get(hass) + + entry = registry.async_get_or_create("test", "super_platform", "very_unique") + + assert entry.device_class is None + assert entry.original_device_class == "best_class" + + deleted_entry = registry.deleted_entities[ + ("test", "super_duper_platform", "very_very_unique") + ] + assert deleted_entry.disabled_by is None + assert deleted_entry.hidden_by is None + assert deleted_entry.options == {} + + # Check migrated data + await flush_store(registry._store) + migrated_data = hass_storage[er.STORAGE_KEY] + assert migrated_data == { + "version": er.STORAGE_VERSION_MAJOR, + "minor_version": er.STORAGE_VERSION_MINOR, + "key": er.STORAGE_KEY, + "data": { + "entities": [ + { + "aliases": [], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": None, + "disabled_by": None, + "entity_category": None, + "entity_id": "test.entity", + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "id": ANY, + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "original_device_class": "best_class", + "original_icon": None, + "original_name": None, + "platform": "super_platform", + "previous_unique_id": None, + "suggested_object_id": None, + "supported_features": 0, + "translation_key": None, + "unique_id": "very_unique", + "unit_of_measurement": None, + "device_class": None, + } + ], + "deleted_entities": [ + { + "aliases": [], + "area_id": None, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_class": None, + "disabled_by": None, + "disabled_by_undefined": False, + "entity_id": "test.deleted_entity", + "hidden_by": None, + "hidden_by_undefined": False, + "icon": None, + "id": "23456", + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "options_undefined": False, + "orphaned_timestamp": None, + "platform": "super_duper_platform", + "unique_id": "very_very_unique", + } + ], + }, + } + + # Serialize the migrated data again + registry.async_schedule_save() + await flush_store(registry._store) + assert hass_storage[er.STORAGE_KEY] == migrated_data + async def test_update_entity_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry @@ -1360,9 +1462,56 @@ async def test_update_entity_unique_id_conflict( ) -async def test_update_entity_entity_id(entity_registry: er.EntityRegistry) -> None: - """Test entity's entity_id is updated.""" +async def test_update_entity_entity_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity's entity_id is updated for entity with a restored state.""" + hass.set_state(CoreState.not_running) + + mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) + entry = entity_registry.async_get_or_create( + "light", "hue", "5678", config_entry=mock_config + ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) + await hass.async_block_till_done() + assert ( + entity_registry.async_get_entity_id("light", "hue", "5678") == entry.entity_id + ) + state = hass.states.get(entry.entity_id) + assert state is not None + assert state.state == "unavailable" + assert state.attributes == {"restored": True, "supported_features": 0} + + new_entity_id = "light.blah" + assert new_entity_id != entry.entity_id + with patch.object(entity_registry, "async_schedule_save") as mock_schedule_save: + updated_entry = entity_registry.async_update_entity( + entry.entity_id, new_entity_id=new_entity_id + ) + assert updated_entry != entry + assert updated_entry.entity_id == new_entity_id + assert mock_schedule_save.call_count == 1 + + assert entity_registry.async_get(entry.entity_id) is None + assert entity_registry.async_get(new_entity_id) is not None + + # The restored state should be removed + old_state = hass.states.get(entry.entity_id) + assert old_state is None + + # The new entity should have an unavailable initial state + new_state = hass.states.get(new_entity_id) + assert new_state is not None + assert new_state.state == "unavailable" + + +async def test_update_entity_entity_id_without_state( + entity_registry: er.EntityRegistry, +) -> None: + """Test entity's entity_id is updated for entity without a state.""" entry = entity_registry.async_get_or_create("light", "hue", "5678") + assert ( entity_registry.async_get_entity_id("light", "hue", "5678") == entry.entity_id ) @@ -1456,6 +1605,257 @@ async def test_update_entity( entry = updated_entry +@pytest.mark.parametrize( + ( + "new_config_entry_disabled_by", + "entity_disabled_by_initial", + "entity_disabled_by_updated", + ), + [ + ( + None, + None, + None, + ), + # Config entry not disabled, entity was disabled by config entry. + # Entity not disabled when updated. + ( + None, + er.RegistryEntryDisabler.CONFIG_ENTRY, + None, + ), + ( + None, + er.RegistryEntryDisabler.DEVICE, + er.RegistryEntryDisabler.DEVICE, + ), + ( + None, + er.RegistryEntryDisabler.HASS, + er.RegistryEntryDisabler.HASS, + ), + ( + None, + er.RegistryEntryDisabler.INTEGRATION, + er.RegistryEntryDisabler.INTEGRATION, + ), + ( + None, + er.RegistryEntryDisabler.USER, + er.RegistryEntryDisabler.USER, + ), + # Config entry disabled, entity not disabled. + # Entity disabled by config entry when updated. + ( + config_entries.ConfigEntryDisabler.USER, + None, + er.RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.CONFIG_ENTRY, + er.RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.DEVICE, + er.RegistryEntryDisabler.DEVICE, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.HASS, + er.RegistryEntryDisabler.HASS, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.INTEGRATION, + er.RegistryEntryDisabler.INTEGRATION, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.USER, + er.RegistryEntryDisabler.USER, + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_entity_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + new_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, + entity_disabled_by_initial: er.RegistryEntryDisabler | None, + entity_disabled_by_updated: er.RegistryEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when updating an entity.""" + config_entry_1 = MockConfigEntry(domain="light") + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + domain="light", disabled_by=new_config_entry_disabled_by + ) + config_entry_2.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry_1, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by_initial, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + # Update entity + entry_updated = entity_registry.async_update_entity( + entry.entity_id, + capabilities={"key2": "value2"}, + config_entry_id=config_entry_2.entry_id, + ) + assert entry != entry_updated + + assert entry_updated == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=config_entry_2.entry_id, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by_updated, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key1": "value1"}}, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + +@pytest.mark.parametrize( + ("entity_disabled_by_initial", "entity_disabled_by_updated"), + [ + (None, None), + # Entity was disabled by config entry, entity not disabled when updated. + (er.RegistryEntryDisabler.CONFIG_ENTRY, None), + (er.RegistryEntryDisabler.DEVICE, er.RegistryEntryDisabler.DEVICE), + (er.RegistryEntryDisabler.HASS, er.RegistryEntryDisabler.HASS), + (er.RegistryEntryDisabler.INTEGRATION, er.RegistryEntryDisabler.INTEGRATION), + (er.RegistryEntryDisabler.USER, er.RegistryEntryDisabler.USER), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_entity_disabled_by_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entity_disabled_by_initial: er.RegistryEntryDisabler | None, + entity_disabled_by_updated: er.RegistryEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when updating an entity. + + In this test, the entity is updated without a config entry. + """ + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by_initial, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + # Update entity + entry_updated = entity_registry.async_update_entity( + entry.entity_id, + capabilities={"key2": "value2"}, + config_entry_id=None, + ) + + assert entry != entry_updated + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_updated == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=None, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by_updated, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key1": "value1"}}, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + async def test_update_entity_options( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -2990,6 +3390,676 @@ async def test_restore_entity( assert update_events[16].data == {"action": "create", "entity_id": "light.hue_1234"} +@pytest.mark.parametrize( + ("entity_disabled_by"), + [ + None, + er.RegistryEntryDisabler.CONFIG_ENTRY, + er.RegistryEntryDisabler.DEVICE, + er.RegistryEntryDisabler.HASS, + er.RegistryEntryDisabler.INTEGRATION, + er.RegistryEntryDisabler.USER, + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_entity_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entity_disabled_by: er.RegistryEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring an entity.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + entity_registry.async_remove(entry.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + + deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] + entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( + deleted_entry, disabled_by=UNDEFINED + ) + + # Re-add entity, integration has changed + entry_restored = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=None, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 0 + assert entry != entry_restored + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_restored == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=config_entry.entry_id, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=er.RegistryEntryHider.INTEGRATION, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key1": "value1"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 3 + assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} + + +@pytest.mark.parametrize( + ("entity_hidden_by"), + [ + None, + er.RegistryEntryHider.INTEGRATION, + er.RegistryEntryHider.USER, + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_entity_hidden_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entity_hidden_by: er.RegistryEntryHider | None, +) -> None: + """Check how the hidden_by flag is treated when restoring an entity.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + entity_registry.async_remove(entry.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + + deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] + entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( + deleted_entry, hidden_by=UNDEFINED + ) + + # Re-add entity, integration has changed + entry_restored = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=entity_hidden_by, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 0 + assert entry != entry_restored + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_restored == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=config_entry.entry_id, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=entity_hidden_by, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key1": "value1"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 3 + assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} + + +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_entity_initial_options( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Check how the initial options is treated when restoring an entity.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + entity_registry.async_remove(entry.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + + deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] + entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( + deleted_entry, options=UNDEFINED + ) + + # Re-add entity, integration has changed + entry_restored = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=None, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 0 + assert entry != entry_restored + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_restored == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=config_entry.entry_id, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=er.RegistryEntryHider.INTEGRATION, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key2": "value2"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 3 + assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "entity_disabled_by_initial", + "entity_disabled_by_restored", + ), + [ + ( + None, + None, + None, + ), + # Config entry not disabled, entity was disabled by config entry. + # Entity not disabled when restored. + ( + None, + er.RegistryEntryDisabler.CONFIG_ENTRY, + None, + ), + ( + None, + er.RegistryEntryDisabler.DEVICE, + er.RegistryEntryDisabler.DEVICE, + ), + ( + None, + er.RegistryEntryDisabler.HASS, + er.RegistryEntryDisabler.HASS, + ), + ( + None, + er.RegistryEntryDisabler.INTEGRATION, + er.RegistryEntryDisabler.INTEGRATION, + ), + ( + None, + er.RegistryEntryDisabler.USER, + er.RegistryEntryDisabler.USER, + ), + # Config entry disabled, entity not disabled. + # Entity disabled by config entry when restored. + ( + config_entries.ConfigEntryDisabler.USER, + None, + er.RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.CONFIG_ENTRY, + er.RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.DEVICE, + er.RegistryEntryDisabler.DEVICE, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.HASS, + er.RegistryEntryDisabler.HASS, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.INTEGRATION, + er.RegistryEntryDisabler.INTEGRATION, + ), + ( + config_entries.ConfigEntryDisabler.USER, + er.RegistryEntryDisabler.USER, + er.RegistryEntryDisabler.USER, + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_entity_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, + entity_disabled_by_initial: er.RegistryEntryDisabler | None, + entity_disabled_by_restored: er.RegistryEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring an entity.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + config_entry = MockConfigEntry(domain="light", disabled_by=config_entry_disabled_by) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by_initial, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + entity_registry.async_remove(entry.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + + # Re-add entity, integration has changed + entry_restored = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=None, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 0 + assert entry != entry_restored + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_restored == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=config_entry.entry_id, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by_restored, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=er.RegistryEntryHider.INTEGRATION, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key1": "value1"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 3 + assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} + + +@pytest.mark.parametrize( + ("entity_disabled_by_initial", "entity_disabled_by_restored"), + [ + (None, None), + # Config entry not disabled, entity was disabled by config entry. + # Entity not disabled when restored. + (er.RegistryEntryDisabler.CONFIG_ENTRY, None), + (er.RegistryEntryDisabler.DEVICE, er.RegistryEntryDisabler.DEVICE), + (er.RegistryEntryDisabler.HASS, er.RegistryEntryDisabler.HASS), + (er.RegistryEntryDisabler.INTEGRATION, er.RegistryEntryDisabler.INTEGRATION), + (er.RegistryEntryDisabler.USER, er.RegistryEntryDisabler.USER), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_entity_disabled_by_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entity_disabled_by_initial: er.RegistryEntryDisabler | None, + entity_disabled_by_restored: er.RegistryEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring an entity. + + In this test, the entity is restored without a config entry. + """ + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by_initial, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + entity_registry.async_remove(entry.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + + # Re-add entity, integration has changed + entry_restored = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=None, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=None, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 0 + assert entry != entry_restored + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_restored == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=None, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by_restored, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=er.RegistryEntryHider.INTEGRATION, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key1": "value1"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 3 + assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} + + async def test_async_migrate_entry_delete_self( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/helpers/test_floor_registry.py b/tests/helpers/test_floor_registry.py index 5ebd63ae302..1cc6dda0964 100644 --- a/tests/helpers/test_floor_registry.py +++ b/tests/helpers/test_floor_registry.py @@ -348,18 +348,47 @@ async def test_async_get_floors_by_alias( floor1 = floor_registry.async_create("First floor", aliases=("alias_1", "alias_2")) floor2 = floor_registry.async_create("Second floor", aliases=("alias_1", "alias_3")) - alias1_list = floor_registry.async_get_floors_by_alias("A l i a s_1") - alias2_list = floor_registry.async_get_floors_by_alias("A l i a s_2") - alias3_list = floor_registry.async_get_floors_by_alias("A l i a s_3") + assert floor_registry.async_get_floors_by_alias("A l i a s_1") == [floor1, floor2] + assert floor_registry.async_get_floors_by_alias("A l i a s_2") == [floor1] + assert floor_registry.async_get_floors_by_alias("A l i a s_3") == [floor2] - assert len(alias1_list) == 2 - assert len(alias2_list) == 1 - assert len(alias3_list) == 1 - assert floor1 in alias1_list - assert floor1 in alias2_list - assert floor2 in alias1_list - assert floor2 in alias3_list +async def test_async_get_floors_by_alias_collisions( + floor_registry: fr.FloorRegistry, +) -> None: + """Make sure we can get the floors by alias when the aliases have collisions.""" + floor = floor_registry.async_create("First floor") + assert floor_registry.async_get_floors_by_alias("A l i a s 1") == [] + + # Add an alias + updated_floor = floor_registry.async_update(floor.floor_id, aliases={"alias1"}) + assert floor_registry.async_get_floors_by_alias("A l i a s 1") == [updated_floor] + + # Add a colliding alias + updated_floor = floor_registry.async_update( + floor.floor_id, aliases={"alias1", "alias 1"} + ) + assert floor_registry.async_get_floors_by_alias("A l i a s 1") == [updated_floor] + + # Add a colliding alias + updated_floor = floor_registry.async_update( + floor.floor_id, aliases={"alias1", "alias 1", "alias 1"} + ) + assert floor_registry.async_get_floors_by_alias("A l i a s 1") == [updated_floor] + + # Remove a colliding alias + updated_floor = floor_registry.async_update( + floor.floor_id, aliases={"alias1", "alias 1"} + ) + assert floor_registry.async_get_floors_by_alias("A l i a s 1") == [updated_floor] + + # Remove a colliding alias + updated_floor = floor_registry.async_update(floor.floor_id, aliases={"alias1"}) + assert floor_registry.async_get_floors_by_alias("A l i a s 1") == [updated_floor] + + # Remove all aliases + updated_floor = floor_registry.async_update(floor.floor_id, aliases={}) + assert floor_registry.async_get_floors_by_alias("A l i a s 1") == [] async def test_async_get_floor_by_name_not_found( diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 9ba93cef4ca..accc681ca9d 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1216,43 +1216,14 @@ async def test_selector_serializer( selector.StateSelector({"entity_id": "sensor.test"}) ) == {"type": "string"} target_schema = selector_serializer(selector.TargetSelector()) - target_schema["properties"]["entity_id"]["anyOf"][0][ - "enum" - ].sort() # Order is not deterministic assert target_schema == { "type": "object", "properties": { - "area_id": { - "anyOf": [ - {"type": "string", "enum": ["none"]}, - {"type": "array", "items": {"type": "string", "nullable": True}}, - ] - }, - "device_id": { - "anyOf": [ - {"type": "string", "enum": ["none"]}, - {"type": "array", "items": {"type": "string", "nullable": True}}, - ] - }, - "entity_id": { - "anyOf": [ - {"type": "string", "enum": ["all", "none"], "format": "lower"}, - {"type": "string", "nullable": True}, - {"type": "array", "items": {"type": "string"}}, - ] - }, - "floor_id": { - "anyOf": [ - {"type": "string", "enum": ["none"]}, - {"type": "array", "items": {"type": "string", "nullable": True}}, - ] - }, - "label_id": { - "anyOf": [ - {"type": "string", "enum": ["none"]}, - {"type": "array", "items": {"type": "string", "nullable": True}}, - ] - }, + "area_id": {"items": {"type": "string"}, "type": "array"}, + "device_id": {"items": {"type": "string"}, "type": "array"}, + "entity_id": {"items": {"type": "string"}, "type": "array"}, + "floor_id": {"items": {"type": "string"}, "type": "array"}, + "label_id": {"items": {"type": "string"}, "type": "array"}, }, "required": [], } @@ -1559,3 +1530,70 @@ This is prompt 2 llm.ToolInput(tool_name="api-2__Tool_2", tool_args={"arg2": "value2"}) ) assert result == {"result": {"Tool_2": {"arg2": "value2"}}} + + +async def test_get_exposed_entities_timestamp_conversion(hass: HomeAssistant) -> None: + """Test that _get_exposed_entities converts timestamp states to local time.""" + assert await async_setup_component(hass, "homeassistant", {}) + + # Set the timezone to something other than UTC to ensure conversion is tested + await hass.config.async_set_time_zone("America/New_York") + + # Set up a timestamp sensor with UTC time + utc_timestamp = "2024-01-15T10:30:00+00:00" + hass.states.async_set( + "sensor.test_timestamp", + utc_timestamp, + {"device_class": "timestamp", "friendly_name": "Test Timestamp"}, + ) + + # Also test with a non-timestamp sensor to ensure it's not affected + hass.states.async_set( + "sensor.regular_sensor", + "2024-01-15T10:30:00+00:00", + {"friendly_name": "Regular Sensor"}, # No device_class + ) + + # And test with invalid/empty timestamp + hass.states.async_set( + "sensor.invalid_timestamp", + "not-a-timestamp", + {"device_class": "timestamp", "friendly_name": "Invalid Timestamp"}, + ) + + hass.states.async_set( + "sensor.empty_timestamp", + "", + {"device_class": "timestamp", "friendly_name": "Empty Timestamp"}, + ) + + # Expose the entities + async_expose_entity(hass, "conversation", "sensor.test_timestamp", True) + async_expose_entity(hass, "conversation", "sensor.regular_sensor", True) + async_expose_entity(hass, "conversation", "sensor.invalid_timestamp", True) + async_expose_entity(hass, "conversation", "sensor.empty_timestamp", True) + + # Call _get_exposed_entities + exposed = llm._get_exposed_entities(hass, "conversation", include_state=True) + + # Check the converted timestamp + sensor_info = exposed["entities"]["sensor.test_timestamp"] + + assert sensor_info["state"] == "2024-01-15T05:30:00-05:00" + # Regular sensor without device_class should keep original value + regular_info = exposed["entities"]["sensor.regular_sensor"] + assert regular_info["state"] == "2024-01-15T10:30:00+00:00" # Unchanged + + # Invalid timestamp should remain as-is + invalid_info = exposed["entities"]["sensor.invalid_timestamp"] + assert invalid_info["state"] == "not-a-timestamp" + + # Empty timestamp should remain empty + empty_info = exposed["entities"]["sensor.empty_timestamp"] + assert empty_info["state"] == "" + + # Test with include_state=False to ensure no conversion happens + exposed_no_state = llm._get_exposed_entities( + hass, "conversation", include_state=False + ) + assert "state" not in exposed_no_state["entities"]["sensor.test_timestamp"] diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index e76faf9ee52..6a5107700ed 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest import voluptuous as vol @@ -317,7 +317,9 @@ async def test_menu_step(hass: HomeAssistant) -> None: """Test menu step.""" MENU_1 = ["option1", "option2"] - MENU_2 = ["option3", "option4"] + + async def menu_2(handler: SchemaCommonFlowHandler) -> list[str]: + return ["option3", "option4"] async def _option1_next_step(_: dict[str, Any]) -> str: return "menu2" @@ -325,7 +327,7 @@ async def test_menu_step(hass: HomeAssistant) -> None: CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { "user": SchemaFlowMenuStep(MENU_1), "option1": SchemaFlowFormStep(vol.Schema({}), next_step=_option1_next_step), - "menu2": SchemaFlowMenuStep(MENU_2), + "menu2": SchemaFlowMenuStep(menu_2), "option3": SchemaFlowFormStep(vol.Schema({}), next_step="option4"), "option4": SchemaFlowFormStep(vol.Schema({})), } @@ -787,3 +789,74 @@ async def test_options_flow_omit_optional_keys( "advanced_default": "a very reasonable default", "optional_default": "a very reasonable default", } + + +@pytest.mark.parametrize( + ( + "new_options", + "expected_loads", + "expected_unloads", + ), + [ + ({}, 1, 0), + ({"some_string": "some_value"}, 2, 1), + ], + ids=["should_not_reload", "should_reload"], +) +async def test_options_flow_with_automatic_reload( + hass: HomeAssistant, + manager: data_entry_flow.FlowManager, + new_options: dict[str, str], + expected_loads: int, + expected_unloads: int, +) -> None: + """Test using options flow with automatic reloading.""" + manager.hass = hass + + OPTIONS_SCHEMA = vol.Schema({vol.Optional("some_string"): str}) + + OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep(OPTIONS_SCHEMA) + } + + class TestFlow(MockSchemaConfigFlowHandler, domain="test"): + config_flow = {} + options_flow = OPTIONS_FLOW + options_flow_reloads = True + + load_entry_mock = AsyncMock(return_value=True) + unload_entry_mock = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=load_entry_mock, + async_unload_entry=unload_entry_mock, + ), + ) + mock_platform(hass, "test.config_flow", None) + config_entry = MockConfigEntry( + data={}, + domain="test", + options={ + "optional_no_default": "abc123", + "optional_default": "not default", + "advanced_no_default": "abc123", + "advanced_default": "not default", + }, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + assert len(load_entry_mock.mock_calls) == 1 + + # Start flow in basic mode + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], new_options + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + assert len(load_entry_mock.mock_calls) == expected_loads + assert len(unload_entry_mock.mock_calls) == expected_unloads diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 7f5255a203b..9628e6f9bf8 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1,5 +1,6 @@ """Test selectors.""" +from collections.abc import Callable, Iterable from enum import Enum from typing import Any @@ -42,7 +43,11 @@ def test_invalid_base_schema(schema) -> None: def _test_selector( - selector_type, schema, valid_selections, invalid_selections, converter=None + selector_type: str, + schema: dict, + valid_selections: Iterable[Any], + invalid_selections: Iterable[Any], + converter: Callable[[Any], Any] | None = None, ): """Help test a selector.""" @@ -1160,6 +1165,7 @@ def test_constant_selector_schema(schema, valid_selections, invalid_selections) @pytest.mark.parametrize( "schema", [ + None, # Value is mandatory {}, # Value is mandatory {"value": []}, # Value must be str, int or bool {"value": 123, "label": 123}, # Label must be str diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 8f094536988..e7cf2c61a76 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,7 +1,7 @@ """Test service helpers.""" import asyncio -from collections.abc import Iterable +from collections.abc import Callable, Iterable from copy import deepcopy import dataclasses import io @@ -36,7 +36,9 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, config_validation as cv, @@ -55,6 +57,7 @@ from homeassistant.util.yaml.loader import parse_yaml from tests.common import ( MockEntity, + MockEntityPlatform, MockModule, MockUser, RegistryEntryWithDefaults, @@ -88,24 +91,28 @@ def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]: available=True, should_poll=False, supported_features=SUPPORT_A, + device_class=None, ) living_room = MockEntity( entity_id="light.living_room", available=True, should_poll=False, supported_features=SUPPORT_B, + device_class="class_a", ) bedroom = MockEntity( entity_id="light.bedroom", available=True, should_poll=False, supported_features=(SUPPORT_A | SUPPORT_B), + device_class="class_b", ) bathroom = MockEntity( entity_id="light.bathroom", available=True, should_poll=False, supported_features=(SUPPORT_B | SUPPORT_C), + device_class="class_c", ) entities = {} entities[kitchen.entity_id] = kitchen @@ -358,6 +365,13 @@ def label_mock(hass: HomeAssistant) -> None: labels={"my-label"}, entity_category=EntityCategory.CONFIG, ) + diag_entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.diag_with_my_label", + unique_id="diag_with_my_label", + platform="test", + labels={"my-label"}, + entity_category=EntityCategory.DIAGNOSTIC, + ) entity_with_label1_from_device = RegistryEntryWithDefaults( entity_id="light.with_label1_from_device", unique_id="with_label1_from_device", @@ -395,6 +409,7 @@ def label_mock(hass: HomeAssistant) -> None: hass, { config_entity_with_my_label.entity_id: config_entity_with_my_label, + diag_entity_with_my_label.entity_id: diag_entity_with_my_label, entity_with_label1_and_label2_from_device.entity_id: entity_with_label1_and_label2_from_device, entity_with_label1_from_device.entity_id: entity_with_label1_from_device, entity_with_label1_from_device_and_different_area.entity_id: entity_with_label1_from_device_and_different_area, @@ -778,6 +793,8 @@ async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: assert { "light.with_my_label", + "light.config_with_my_label", + "light.diag_with_my_label", } == await service.async_extract_entity_ids(hass, call) call = ServiceCall(hass, "light", "turn_on", {"label_id": "label1"}) @@ -824,7 +841,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: # Test we only load services.yaml for integrations with services.yaml # And system_health has no services - assert proxy_load_services_files.mock_calls[0][1][1] == unordered( + assert proxy_load_services_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, DOMAIN_GROUP), ] @@ -977,7 +994,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: descriptions = await service.async_get_all_descriptions(hass) mock_load_yaml.assert_called_once_with("services.yaml", None) - assert proxy_load_services_files.mock_calls[0][1][1] == unordered( + assert proxy_load_services_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, domain), ] @@ -987,7 +1004,16 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: "test_domain": { "test_service": { "description": "", - "fields": {"test": {"selector": {"text": {}}}}, + "fields": { + "test": { + "selector": { + "text": { + "multiline": False, + "multiple": False, + } + } + } + }, "name": "", } } @@ -1063,7 +1089,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: descriptions = await service.async_get_all_descriptions(hass) mock_load_yaml.assert_called_once_with("services.yaml", None) - assert proxy_load_services_files.mock_calls[0][1][1] == unordered( + assert proxy_load_services_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, domain), ] @@ -1079,7 +1105,12 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": {}}, + "selector": { + "number": { + "mode": "box", + "step": 1.0, + } + }, }, "entity": { "selector": { @@ -1102,7 +1133,12 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": {}}, + "selector": { + "number": { + "mode": "box", + "step": 1.0, + } + }, }, "entity": { "selector": { @@ -1383,7 +1419,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - mock_entities["light.bedroom"], ] actual = [call[0][0] for call in test_service_mock.call_args_list] - assert all(entity in actual for entity in expected) + assert actual == unordered(expected) # Test we raise if we target entity ID that does not support the service test_service_mock.reset_mock() @@ -1443,7 +1479,70 @@ async def test_call_with_one_of_required_features( mock_entities["light.bathroom"], ] actual = [call[0][0] for call in test_service_mock.call_args_list] - assert all(entity in actual for entity in expected) + assert actual == unordered(expected) + + +@pytest.mark.parametrize( + ("entity_device_classes", "expected_entities", "unsupported_entity"), + [ + ( + [None], + ["light.kitchen"], + "light.living_room", + ), + ( + ["class_a"], + ["light.living_room"], + "light.kitchen", + ), + ( + [None, "class_a"], + ["light.kitchen", "light.living_room"], + "light.bedroom", + ), + ], +) +async def test_call_with_device_class( + hass: HomeAssistant, + mock_entities, + entity_device_classes: list[str | None], + expected_entities: list[str], + unsupported_entity: str, +) -> None: + """Test service calls invoked only if entity has required features.""" + # Set up homeassistant component to fetch the translations + await async_setup_component(hass, "homeassistant", {}) + test_service_mock = AsyncMock(return_value=None) + await service.entity_service_call( + hass, + mock_entities, + HassJob(test_service_mock), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), + entity_device_classes=entity_device_classes, + ) + + assert test_service_mock.call_count == len(expected_entities) + expected = [mock_entities[expected_entity] for expected_entity in expected_entities] + actual = [call[0][0] for call in test_service_mock.call_args_list] + assert actual == unordered(expected) + + # Test we raise if we target entity ID that does not support the service + test_service_mock.reset_mock() + with pytest.raises( + exceptions.ServiceNotSupported, + match=f"Entity {unsupported_entity} does not " + "support action test_domain.test_service", + ): + await service.entity_service_call( + hass, + mock_entities, + HassJob(test_service_mock), + ServiceCall( + hass, "test_domain", "test_service", {"entity_id": unsupported_entity} + ), + entity_device_classes=entity_device_classes, + ) + assert test_service_mock.call_count == 0 async def test_call_with_sync_func(hass: HomeAssistant, mock_entities) -> None: @@ -1753,7 +1852,28 @@ async def test_register_admin_service_return_response( assert result == {"test-reply": "test-value1"} -async def test_domain_control_not_async(hass: HomeAssistant, mock_entities) -> None: +_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE = ( + "The deprecated argument hass was passed to verify_domain_control. It will be" + " removed in HA Core 2026.10. Use verify_domain_control without hass argument" + " instead" +) + + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) +async def test_domain_control_not_async( + hass: HomeAssistant, + mock_entities, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, +) -> None: """Test domain verification in a service call with an unknown user.""" calls = [] @@ -1762,10 +1882,26 @@ async def test_domain_control_not_async(hass: HomeAssistant, mock_entities) -> N calls.append(call) with pytest.raises(exceptions.HomeAssistantError): - service.verify_domain_control(hass, "test_domain")(mock_service_log) + decorator(hass, "test_domain")(mock_service_log) + + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog -async def test_domain_control_unknown(hass: HomeAssistant, mock_entities) -> None: +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) +async def test_domain_control_unknown( + hass: HomeAssistant, + mock_entities, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, +) -> None: """Test domain verification in a service call with an unknown user.""" calls = [] @@ -1777,9 +1913,7 @@ async def test_domain_control_unknown(hass: HomeAssistant, mock_entities) -> Non "homeassistant.helpers.entity_registry.async_get", return_value=Mock(entities=mock_entities), ): - protected_mock_service = service.verify_domain_control(hass, "test_domain")( - mock_service_log - ) + protected_mock_service = decorator(hass, "test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -1795,9 +1929,23 @@ async def test_domain_control_unknown(hass: HomeAssistant, mock_entities) -> Non ) assert len(calls) == 0 + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) async def test_domain_control_unauthorized( - hass: HomeAssistant, hass_read_only_user: MockUser + hass: HomeAssistant, + hass_read_only_user: MockUser, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, ) -> None: """Test domain verification in a service call with an unauthorized user.""" mock_registry( @@ -1817,9 +1965,7 @@ async def test_domain_control_unauthorized( """Define a protected service.""" calls.append(call) - protected_mock_service = service.verify_domain_control(hass, "test_domain")( - mock_service_log - ) + protected_mock_service = decorator(hass, "test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -1836,9 +1982,23 @@ async def test_domain_control_unauthorized( assert len(calls) == 0 + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) async def test_domain_control_admin( - hass: HomeAssistant, hass_admin_user: MockUser + hass: HomeAssistant, + hass_admin_user: MockUser, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, ) -> None: """Test domain verification in a service call with an admin user.""" mock_registry( @@ -1858,9 +2018,7 @@ async def test_domain_control_admin( """Define a protected service.""" calls.append(call) - protected_mock_service = service.verify_domain_control(hass, "test_domain")( - mock_service_log - ) + protected_mock_service = decorator(hass, "test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -1876,8 +2034,23 @@ async def test_domain_control_admin( assert len(calls) == 1 + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog -async def test_domain_control_no_user(hass: HomeAssistant) -> None: + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) +async def test_domain_control_no_user( + hass: HomeAssistant, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, +) -> None: """Test domain verification in a service call with no user.""" mock_registry( hass, @@ -1896,9 +2069,7 @@ async def test_domain_control_no_user(hass: HomeAssistant) -> None: """Define a protected service.""" calls.append(call) - protected_mock_service = service.verify_domain_control(hass, "test_domain")( - mock_service_log - ) + protected_mock_service = decorator(hass, "test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -1914,6 +2085,8 @@ async def test_domain_control_no_user(hass: HomeAssistant) -> None: assert len(calls) == 1 + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog + async def test_extract_from_service_available_device(hass: HomeAssistant) -> None: """Test the extraction of entity from service and device is available.""" @@ -2442,3 +2615,327 @@ async def test_deprecated_async_extract_referenced_entity_ids( assert args[0][2] is False assert dataclasses.asdict(result) == dataclasses.asdict(mock_selected) + + +async def test_register_platform_entity_service( + hass: HomeAssistant, +) -> None: + """Test registering a platform entity service.""" + entities = [] + + @callback + def handle_service(entity, *_): + entities.append(entity) + + service.async_register_platform_entity_service( + hass, + "mock_platform", + "hello", + entity_domain="mock_integration", + schema={}, + func=handle_service, + ) + + await hass.services.async_call( + "mock_platform", "hello", {"entity_id": "all"}, blocking=True + ) + assert entities == [] + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id="mock_integration.entity1") + entity2 = MockEntity(entity_id="mock_integration.entity2") + await entity_platform.async_add_entities([entity1, entity2]) + + await hass.services.async_call( + "mock_platform", "hello", {"entity_id": "all"}, blocking=True + ) + + assert entities == unordered([entity1, entity2]) + + +async def test_register_platform_entity_service_response_data( + hass: HomeAssistant, +) -> None: + """Test an entity service that supports response data.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": "response-value"} + + service.async_register_platform_entity_service( + hass, + "mock_platform", + "hello", + entity_domain="mock_integration", + schema={"some": str}, + func=generate_response, + supports_response=SupportsResponse.ONLY, + ) + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity = MockEntity(entity_id="mock_integration.entity") + await entity_platform.async_add_entities([entity]) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity.entity_id]}, + blocking=True, + return_response=True, + ) + assert response_data == { + "mock_integration.entity": {"response-key": "response-value"} + } + + +async def test_register_platform_entity_service_response_data_multiple_matches( + hass: HomeAssistant, +) -> None: + """Test an entity service with response data and matching many entities.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": f"response-value-{target.entity_id}"} + + service.async_register_platform_entity_service( + hass, + "mock_platform", + "hello", + entity_domain="mock_integration", + schema={"some": str}, + func=generate_response, + supports_response=SupportsResponse.ONLY, + ) + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id="mock_integration.entity1") + entity2 = MockEntity(entity_id="mock_integration.entity2") + await entity_platform.async_add_entities([entity1, entity2]) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + assert response_data == { + "mock_integration.entity1": { + "response-key": "response-value-mock_integration.entity1" + }, + "mock_integration.entity2": { + "response-key": "response-value-mock_integration.entity2" + }, + } + + +async def test_register_platform_entity_service_response_data_multiple_matches_raises( + hass: HomeAssistant, +) -> None: + """Test entity service response matching many entities raises.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + if target.entity_id == "mock_integration.entity1": + raise RuntimeError("Something went wrong") + return {"response-key": f"response-value-{target.entity_id}"} + + service.async_register_platform_entity_service( + hass, + "mock_platform", + "hello", + entity_domain="mock_integration", + schema={"some": str}, + func=generate_response, + supports_response=SupportsResponse.ONLY, + ) + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id="mock_integration.entity1") + entity2 = MockEntity(entity_id="mock_integration.entity2") + await entity_platform.async_add_entities([entity1, entity2]) + + with pytest.raises(RuntimeError, match="Something went wrong"): + await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + + +async def test_register_platform_entity_service_limited_to_matching_platforms( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test entity services only target entities for the platform and domain.""" + + mock_area = area_registry.async_get_or_create("mock_area") + + entity1_entry = entity_registry.async_get_or_create( + "base_platform", "mock_platform", "1234", suggested_object_id="entity1" + ) + entity_registry.async_update_entity(entity1_entry.entity_id, area_id=mock_area.id) + entity2_entry = entity_registry.async_get_or_create( + "base_platform", "mock_platform", "5678", suggested_object_id="entity2" + ) + entity_registry.async_update_entity(entity2_entry.entity_id, area_id=mock_area.id) + entity3_entry = entity_registry.async_get_or_create( + "base_platform", "other_mock_platform", "7891", suggested_object_id="entity3" + ) + entity_registry.async_update_entity(entity3_entry.entity_id, area_id=mock_area.id) + entity4_entry = entity_registry.async_get_or_create( + "base_platform", "other_mock_platform", "1433", suggested_object_id="entity4" + ) + entity_registry.async_update_entity(entity4_entry.entity_id, area_id=mock_area.id) + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": f"response-value-{target.entity_id}"} + + service.async_register_platform_entity_service( + hass, + "mock_platform", + "hello", + entity_domain="base_platform", + schema={"some": str}, + func=generate_response, + supports_response=SupportsResponse.ONLY, + ) + + entity_platform = MockEntityPlatform( + hass, domain="base_platform", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity( + entity_id=entity1_entry.entity_id, unique_id=entity1_entry.unique_id + ) + entity2 = MockEntity( + entity_id=entity2_entry.entity_id, unique_id=entity2_entry.unique_id + ) + await entity_platform.async_add_entities([entity1, entity2]) + + other_entity_platform = MockEntityPlatform( + hass, domain="base_platform", platform_name="other_mock_platform", platform=None + ) + entity3 = MockEntity( + entity_id=entity3_entry.entity_id, unique_id=entity3_entry.unique_id + ) + entity4 = MockEntity( + entity_id=entity4_entry.entity_id, unique_id=entity4_entry.unique_id + ) + await other_entity_platform.async_add_entities([entity3, entity4]) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"area_id": [mock_area.id]}, + blocking=True, + return_response=True, + ) + # We should not target entity3 and entity4 even though they are in the area + # because they are only part of the domain and not the platform + assert response_data == { + "base_platform.entity1": { + "response-key": "response-value-base_platform.entity1" + }, + "base_platform.entity2": { + "response-key": "response-value-base_platform.entity2" + }, + } + + +async def test_register_platform_entity_service_none_schema( + hass: HomeAssistant, +) -> None: + """Test registering a service with schema set to None.""" + entities = [] + + @callback + def handle_service(entity, *_): + entities.append(entity) + + service.async_register_platform_entity_service( + hass, + "mock_platform", + "hello", + entity_domain="mock_integration", + schema=None, + func=handle_service, + ) + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(name="entity_1") + entity2 = MockEntity(name="entity_1") + await entity_platform.async_add_entities([entity1, entity2]) + + await hass.services.async_call( + "mock_platform", "hello", {"entity_id": "all"}, blocking=True + ) + + assert len(entities) == 2 + assert entity1 in entities + assert entity2 in entities + + +async def test_register_platform_entity_service_non_entity_service_schema( + hass: HomeAssistant, +) -> None: + """Test attempting to register a service with a non entity service schema.""" + expected_message = "registers an entity service with a non entity service schema" + + for idx, schema in enumerate( + ( + vol.Schema({"some": str}), + vol.All(vol.Schema({"some": str})), + vol.Any(vol.Schema({"some": str})), + ) + ): + with pytest.raises(HomeAssistantError, match=expected_message): + service.async_register_platform_entity_service( + hass, + "mock_platform", + f"hello_{idx}", + entity_domain="mock_integration", + schema=schema, + func=Mock(), + ) + + for idx, schema in enumerate( + ( + cv.make_entity_service_schema({"some": str}), + vol.Schema(cv.make_entity_service_schema({"some": str})), + vol.All(cv.make_entity_service_schema({"some": str})), + ) + ): + service.async_register_platform_entity_service( + hass, + "mock_platform", + f"test_service_{idx}", + entity_domain="mock_integration", + schema=schema, + func=Mock(), + ) diff --git a/tests/helpers/test_service_info.py b/tests/helpers/test_service_info.py new file mode 100644 index 00000000000..ecc017c729e --- /dev/null +++ b/tests/helpers/test_service_info.py @@ -0,0 +1,37 @@ +"""Test service_info helpers.""" + +import pytest + +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo + +# Ensure that incorrectly formatted mac addresses are rejected, even +# on a constant outside of a test +try: + _ = DhcpServiceInfo(ip="", hostname="", macaddress="AA:BB:CC:DD:EE:FF") +except ValueError: + pass +else: + raise RuntimeError( + "DhcpServiceInfo incorrectly formatted mac address was not rejected. " + "Please ensure that the DhcpServiceInfo is correctly patched." + ) + + +def test_invalid_macaddress() -> None: + """Test that DhcpServiceInfo raises ValueError for unformatted macaddress.""" + with pytest.raises(ValueError): + DhcpServiceInfo(ip="", hostname="", macaddress="AA:BB:CC:DD:EE:FF") + + +def test_esphome_socket_path() -> None: + """Test ESPHomeServiceInfo socket_path property.""" + info = ESPHomeServiceInfo( + name="Hello World", + zwave_home_id=123456789, + ip_address="192.168.1.100", + port=6053, + ) + assert info.socket_path == "esphome://192.168.1.100:6053" + info.noise_psk = "my-noise-psk" + assert info.socket_path == "esphome://my-noise-psk@192.168.1.100:6053" diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 09fb16cbe9a..3c19a9c9a43 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -245,6 +245,13 @@ def registries_mock(hass: HomeAssistant) -> None: labels={"my-label"}, entity_category=EntityCategory.CONFIG, ) + diag_entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.diag_with_my_label", + unique_id="diag_with_my_label", + platform="test", + labels={"my-label"}, + entity_category=EntityCategory.DIAGNOSTIC, + ) entity_with_label1_from_device = RegistryEntryWithDefaults( entity_id="light.with_label1_from_device", unique_id="with_label1_from_device", @@ -289,6 +296,7 @@ def registries_mock(hass: HomeAssistant) -> None: entity_in_area_a.entity_id: entity_in_area_a, entity_in_area_b.entity_id: entity_in_area_b, config_entity_with_my_label.entity_id: config_entity_with_my_label, + diag_entity_with_my_label.entity_id: diag_entity_with_my_label, entity_with_label1_and_label2_from_device.entity_id: entity_with_label1_and_label2_from_device, entity_with_label1_from_device.entity_id: entity_with_label1_from_device, entity_with_label1_from_device_and_different_area.entity_id: entity_with_label1_from_device_and_different_area, @@ -407,7 +415,11 @@ def registries_mock(hass: HomeAssistant) -> None: {ATTR_LABEL_ID: "my-label"}, False, target.SelectedEntities( - indirectly_referenced={"light.with_my_label"}, + indirectly_referenced={ + "light.with_my_label", + "light.config_with_my_label", + "light.diag_with_my_label", + }, missing_labels={"my-label"}, ), ), diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index d5621a1ae61..0a271057ad5 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -19,11 +19,13 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import trigger +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, PluggableAction, Trigger, TriggerActionType, + TriggerConfig, TriggerInfo, _async_get_trigger_platform, async_initialize_triggers, @@ -54,14 +56,10 @@ async def test_trigger_subtype(hass: HomeAssistant) -> None: assert integration_mock.call_args == call(hass, "test") -async def test_trigger_variables(hass: HomeAssistant) -> None: - """Test trigger variables.""" - - -async def test_if_fires_on_event( +async def test_trigger_variables( hass: HomeAssistant, service_calls: list[ServiceCall] ) -> None: - """Test the firing of events.""" + """Test trigger variables.""" assert await async_setup_component( hass, "automation", @@ -457,9 +455,6 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: class MockTrigger(Trigger): """Mock trigger.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize trigger.""" - @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType @@ -467,6 +462,9 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: """Validate config.""" return config + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize trigger.""" + class MockTrigger1(MockTrigger): """Mock trigger 1.""" @@ -489,9 +487,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: """Attach a trigger.""" action({"trigger": "test_trigger_2"}) - async def async_get_triggers( - hass: HomeAssistant, - ) -> dict[str, type[Trigger]]: + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "_": MockTrigger1, "trig_2": MockTrigger2, @@ -501,7 +497,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) config_1 = [{"platform": "test"}] - config_2 = [{"platform": "test.trig_2"}] + config_2 = [{"platform": "test.trig_2", "options": {"x": 1}}] config_3 = [{"platform": "test.unknown_trig"}] assert await async_validate_trigger_config(hass, config_1) == config_1 assert await async_validate_trigger_config(hass, config_2) == config_2 @@ -530,6 +526,53 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb) +async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: + """Test a trigger platform with a migration.""" + + OPTIONS_SCHEMA_DICT = { + vol.Required("option_1"): str, + vol.Optional("option_2"): int, + } + + class MockTrigger(Trigger): + """Mock trigger.""" + + @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 config + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return { + "_": MockTrigger, + } + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + config_1 = [{"platform": "test", "option_1": "value_1", "option_2": 2}] + config_2 = [{"platform": "test", "option_1": "value_1"}] + config_3 = [{"platform": "test", "options": {"option_1": "value_1", "option_2": 2}}] + config_4 = [{"platform": "test", "options": {"option_1": "value_1"}}] + + assert await async_validate_trigger_config(hass, config_1) == config_3 + assert await async_validate_trigger_config(hass, config_2) == config_4 + assert await async_validate_trigger_config(hass, config_3) == config_3 + assert await async_validate_trigger_config(hass, config_4) == config_4 + + @pytest.mark.parametrize( "sun_trigger_descriptions", [ diff --git a/tests/patch_time.py b/tests/patch_time.py index 76d31d6a75a..c61e6291740 100644 --- a/tests/patch_time.py +++ b/tests/patch_time.py @@ -26,8 +26,31 @@ def ha_datetime_to_fakedatetime(datetime) -> freezegun.api.FakeDatetime: # type ) -class HAFakeDatetime(freezegun.api.FakeDatetime): # type: ignore[name-defined] - """Modified to include https://github.com/spulec/freezegun/pull/424.""" +class HAFakeDateMeta(freezegun.api.FakeDateMeta): + """Modified to override the string representation.""" + + def __str__(cls) -> str: # noqa: N805 (ruff doesn't know this is a metaclass) + """Return the string representation of the class.""" + return "" + + +class HAFakeDate(freezegun.api.FakeDate, metaclass=HAFakeDateMeta): # type: ignore[name-defined] + """Modified to improve class str.""" + + +class HAFakeDatetimeMeta(freezegun.api.FakeDatetimeMeta): + """Modified to override the string representation.""" + + def __str__(cls) -> str: # noqa: N805 (ruff doesn't know this is a metaclass) + """Return the string representation of the class.""" + return "" + + +class HAFakeDatetime(freezegun.api.FakeDatetime, metaclass=HAFakeDatetimeMeta): # type: ignore[name-defined] + """Modified to include basic fold support and improve class str. + + Fold support submitted to upstream in https://github.com/spulec/freezegun/pull/424. + """ @classmethod def now(cls, tz=None): diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 41605bf2f2b..fac9cf0785c 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1497,3 +1497,39 @@ def test_invalid_generic( ), ): type_hint_checker.visit_asyncfunctiondef(func_node) + + +def test_missing_argument( + linter: UnittestLinter, + type_hint_checker: BaseChecker, +) -> None: + """Ensure missing arg raises an error.""" + func_node = astroid.extract_node( + """ + async def async_setup_entry( #@ + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + pass + """, + "homeassistant.components.pylint_test.sensor", + ) + type_hint_checker.visit_module(func_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=func_node, + args=( + 3, + "AddConfigEntryEntitiesCallback", + "async_setup_entry", + ), + line=2, + col_offset=0, + end_line=2, + end_col_offset=27, + ), + ): + type_hint_checker.visit_asyncfunctiondef(func_node) diff --git a/tests/syrupy.py b/tests/syrupy.py index 919ba1a6cea..642e5a519b2 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -175,7 +175,6 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serialized.pop("_cache") # This can be removed when suggested_area is removed from DeviceEntry serialized.pop("_suggested_area") - serialized.pop("is_new") return cls._remove_created_and_modified_at(serialized) @classmethod diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 9e1f246b551..604b375d299 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -38,6 +38,17 @@ from .common import ( VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) +CONFIG_LOG_FILE = get_test_config_dir("home-assistant.log") +ARG_LOG_FILE = "test.log" + + +def cleanup_log_files() -> None: + """Remove all log files.""" + for f in glob.glob(f"{CONFIG_LOG_FILE}*"): + os.remove(f) + for f in glob.glob(f"{ARG_LOG_FILE}*"): + os.remove(f) + @pytest.fixture(autouse=True) def disable_installed_check() -> Generator[None]: @@ -85,16 +96,11 @@ async def test_async_enable_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test to ensure logging is migrated to the queue handlers.""" - config_log_file_pattern = get_test_config_dir("home-assistant.log*") - arg_log_file_pattern = "test.log*" # Ensure we start with a clean slate - for f in glob.glob(arg_log_file_pattern): - os.remove(f) - for f in glob.glob(config_log_file_pattern): - os.remove(f) - assert len(glob.glob(config_log_file_pattern)) == 0 - assert len(glob.glob(arg_log_file_pattern)) == 0 + cleanup_log_files() + assert len(glob.glob(CONFIG_LOG_FILE)) == 0 + assert len(glob.glob(ARG_LOG_FILE)) == 0 with ( patch("logging.getLogger"), @@ -108,7 +114,7 @@ async def test_async_enable_logging( ): await bootstrap.async_enable_logging(hass) mock_async_activate_log_queue_handler.assert_called_once() - assert len(glob.glob(config_log_file_pattern)) > 0 + assert len(glob.glob(CONFIG_LOG_FILE)) > 0 mock_async_activate_log_queue_handler.reset_mock() await bootstrap.async_enable_logging( @@ -117,14 +123,61 @@ async def test_async_enable_logging( log_file="test.log", ) mock_async_activate_log_queue_handler.assert_called_once() - assert len(glob.glob(arg_log_file_pattern)) > 0 + assert len(glob.glob(ARG_LOG_FILE)) > 0 assert "Error rolling over log file" in caplog.text - for f in glob.glob(arg_log_file_pattern): - os.remove(f) - for f in glob.glob(config_log_file_pattern): - os.remove(f) + cleanup_log_files() + + +async def test_async_enable_logging_supervisor( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test to ensure the default log file is not created on Supervisor installations.""" + + # Ensure we start with a clean slate + cleanup_log_files() + assert len(glob.glob(CONFIG_LOG_FILE)) == 0 + assert len(glob.glob(ARG_LOG_FILE)) == 0 + + with ( + patch.dict(os.environ, {"SUPERVISOR": "1"}), + patch( + "homeassistant.bootstrap.async_activate_log_queue_handler" + ) as mock_async_activate_log_queue_handler, + patch("logging.getLogger"), + ): + await bootstrap.async_enable_logging(hass) + assert len(glob.glob(CONFIG_LOG_FILE)) == 0 + mock_async_activate_log_queue_handler.assert_called_once() + mock_async_activate_log_queue_handler.reset_mock() + + # Check that if the log file exists, it is renamed + def write_log_file(): + with open( + get_test_config_dir("home-assistant.log"), "w", encoding="utf8" + ) as f: + f.write("test") + + await hass.async_add_executor_job(write_log_file) + assert len(glob.glob(CONFIG_LOG_FILE)) == 1 + assert len(glob.glob(f"{CONFIG_LOG_FILE}.old")) == 0 + await bootstrap.async_enable_logging(hass) + assert len(glob.glob(CONFIG_LOG_FILE)) == 0 + assert len(glob.glob(f"{CONFIG_LOG_FILE}.old")) == 1 + mock_async_activate_log_queue_handler.assert_called_once() + mock_async_activate_log_queue_handler.reset_mock() + + await bootstrap.async_enable_logging( + hass, + log_rotate_days=5, + log_file="test.log", + ) + mock_async_activate_log_queue_handler.assert_called_once() + # Even on Supervisor, the log file should be created if it is explicitly specified + assert len(glob.glob(ARG_LOG_FILE)) > 0 + + cleanup_log_files() async def test_load_hassio(hass: HomeAssistant) -> None: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 9a62fd421b7..4619d49584a 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -31,7 +31,12 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo, FlowResult, FlowResultType +from homeassistant.data_entry_flow import ( + BaseServiceInfo, + FlowResult, + FlowResultType, + UnknownFlow, +) from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -1838,6 +1843,177 @@ async def test_reload_during_setup_retrying_waits(hass: HomeAssistant) -> None: ] +async def test_create_entry_next_flow( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test next_flow parameter for create entry.""" + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Mock setup.""" + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", + async_setup=mock_async_setup, + async_setup_entry=async_setup_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Test create entry with next_flow parameter.""" + result = await hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_USER}, + ) + return self.async_create_entry( + title="import", + data={"flow": "import"}, + next_flow=(config_entries.FlowType.CONFIG_FLOW, result["flow_id"]), + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Test next step.""" + if user_input is None: + return self.async_show_form(step_id="user") + return self.async_create_entry(title="user", data={"flow": "user"}) + + with mock_config_flow("comp", TestFlow): + assert await async_setup_component(hass, "comp", {}) + + result = await hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + ) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + user_flow = flows[0] + assert async_setup_entry.call_count == 1 + + entries = hass.config_entries.async_entries("comp") + assert len(entries) == 1 + entry = entries[0] + assert result == { + "context": {"source": "import"}, + "data": {"flow": "import"}, + "description_placeholders": None, + "description": None, + "flow_id": ANY, + "handler": "comp", + "minor_version": 1, + "next_flow": (config_entries.FlowType.CONFIG_FLOW, user_flow["flow_id"]), + "options": {}, + "result": entry, + "subentries": (), + "title": "import", + "type": FlowResultType.CREATE_ENTRY, + "version": 1, + } + + result = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], {} + ) + await hass.async_block_till_done() + + assert async_setup_entry.call_count == 2 + entries = hass.config_entries.async_entries("comp") + entry = next(entry for entry in entries if entry.data.get("flow") == "user") + assert result == { + "context": {"source": "user"}, + "data": {"flow": "user"}, + "description_placeholders": None, + "description": None, + "flow_id": user_flow["flow_id"], + "handler": "comp", + "minor_version": 1, + "options": {}, + "result": entry, + "subentries": (), + "title": "user", + "type": FlowResultType.CREATE_ENTRY, + "version": 1, + } + + +@pytest.mark.parametrize( + ("invalid_next_flow", "error"), + [ + (("invalid_flow_type", "invalid_flow_id"), HomeAssistantError), + ((config_entries.FlowType.CONFIG_FLOW, "invalid_flow_id"), UnknownFlow), + ], +) +async def test_create_entry_next_flow_invalid( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + invalid_next_flow: tuple[str, str], + error: type[Exception], +) -> None: + """Test next_flow invalid parameter for create entry.""" + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Mock setup.""" + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", + async_setup=mock_async_setup, + async_setup_entry=async_setup_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Test create entry with next_flow parameter.""" + await hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_USER}, + ) + return self.async_create_entry( + title="import", + data={"flow": "import"}, + next_flow=invalid_next_flow, # type: ignore[arg-type] + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Test next step.""" + if user_input is None: + return self.async_show_form(step_id="user") + return self.async_create_entry(title="user", data={"flow": "user"}) + + with mock_config_flow("comp", TestFlow): + assert await async_setup_component(hass, "comp", {}) + + with pytest.raises(error): + await hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + ) + + assert async_setup_entry.call_count == 0 + + async def test_create_entry_options( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -4622,7 +4798,7 @@ async def test_flow_same_device_multiple_sources( flow3 = manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_HOMEKIT} ) - result1, result2, result3 = await asyncio.gather(flow1, flow2, flow3) + _result1, result2, _result3 = await asyncio.gather(flow1, flow2, flow3) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -4784,6 +4960,68 @@ async def test_entry_state_change_calls_listener( assert entry.state is target_state +@pytest.mark.parametrize( + ("source_state", "target_state", "transition_method_name", "call_count"), + [ + ( + config_entries.ConfigEntryState.NOT_LOADED, + config_entries.ConfigEntryState.LOADED, + "async_setup", + 2, + ), + ( + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.NOT_LOADED, + "async_unload", + 1, + ), + ( + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.LOADED, + "async_reload", + 1, + ), + ], +) +async def test_entry_state_change_wrapped_in_on_unload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + source_state: config_entries.ConfigEntryState, + target_state: config_entries.ConfigEntryState, + transition_method_name: str, + call_count: int, +) -> None: + """Test listeners get called on entry state changes. + + This test wraps the listener in async_on_unload, the expectation is that + `async_on_unload` is called before the state changes to NOT_LOADED so the + listener is not called when the entry is unloaded. + """ + entry = MockConfigEntry(domain="comp", state=source_state) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=AsyncMock(return_value=True), + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + + mock_state_change_callback = Mock() + entry.async_on_unload(entry.async_on_state_change(mock_state_change_callback)) + + transition_method = getattr(manager, transition_method_name) + await transition_method(entry.entry_id) + + assert len(mock_state_change_callback.mock_calls) == call_count + assert entry.state is target_state + + async def test_entry_state_change_listener_removed( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -5272,6 +5510,19 @@ async def test_async_abort_entries_match( assert result["type"] == FlowResultType.ABORT assert result["reason"] == reason + # For a domain with no entries, there should never be a match + mock_integration(hass, MockModule("not_comp", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "not_comp.config_flow", None) + + with mock_config_flow("not_comp", TestFlow), mock_config_flow("invalid_flow", 5): + result = await manager.flow.async_init( + "not_comp", context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_match" + @pytest.mark.parametrize( ("matchers", "reason"), diff --git a/tests/test_const.py b/tests/test_const.py index 3398a571f6f..4413e8efe96 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant import const -from homeassistant.components import alarm_control_panel, lock +from homeassistant.components import alarm_control_panel from .common import ( extract_stack_to_frame, @@ -52,53 +52,15 @@ def test_deprecated_constant_name_changes( ) -def _create_tuples_lock_states( - enum: type[Enum], constant_prefix: str, remove_in_version: str -) -> list[tuple[Enum, str]]: - return [ - (enum_field, constant_prefix, remove_in_version) - for enum_field in enum - if enum_field - not in [ - lock.LockState.OPEN, - lock.LockState.OPENING, - ] - ] - - -@pytest.mark.parametrize( - ("enum", "constant_prefix", "remove_in_version"), - _create_tuples_lock_states(lock.LockState, "STATE_", "2025.10"), -) -def test_deprecated_constants_lock( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, - remove_in_version: str, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, const, enum, constant_prefix, remove_in_version - ) - - def _create_tuples_alarm_states( enum: type[Enum], constant_prefix: str, remove_in_version: str ) -> list[tuple[Enum, str]]: - return [ - (enum_field, constant_prefix, remove_in_version) - for enum_field in enum - if enum_field - not in [ - lock.LockState.OPEN, - lock.LockState.OPENING, - ] - ] + return [(enum_field, constant_prefix, remove_in_version) for enum_field in enum] @pytest.mark.parametrize( ("enum", "constant_prefix", "remove_in_version"), - _create_tuples_lock_states( + _create_tuples_alarm_states( alarm_control_panel.AlarmControlPanelState, "STATE_ALARM_", "2025.11" ), ) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index f0912188b9e..55ff79e2531 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -1071,13 +1071,19 @@ async def test_manager_abort_calls_async_flow_removed(manager: MockFlowManager) @pytest.mark.parametrize( - "menu_options", - [["target1", "target2"], {"target1": "Target 1", "target2": "Target 2"}], + ("menu_options", "sort", "expect_sort"), + [ + (["target1", "target2"], None, None), + ({"target1": "Target 1", "target2": "Target 2"}, False, None), + (["target2", "target1"], True, True), + ], ) async def test_show_menu( hass: HomeAssistant, manager: MockFlowManager, menu_options: list[str] | dict[str, str], + sort: bool | None, + expect_sort: bool | None, ) -> None: """Test show menu.""" manager.hass = hass @@ -1093,6 +1099,7 @@ async def test_show_menu( step_id="init", menu_options=menu_options, description_placeholders={"name": "Paulus"}, + sort=sort, ) async def async_step_target1(self, user_input=None): @@ -1105,6 +1112,7 @@ async def test_show_menu( assert result["type"] == data_entry_flow.FlowResultType.MENU assert result["menu_options"] == menu_options assert result["description_placeholders"] == {"name": "Paulus"} + assert result.get("sort") == expect_sort assert len(manager.async_progress()) == 1 assert len(manager.async_progress_by_handler("test")) == 1 assert manager.async_get(result["flow_id"])["handler"] == "test" diff --git a/tests/test_runner.py b/tests/test_runner.py index c61b8ed5628..6da9839f6fb 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -2,15 +2,21 @@ import asyncio from collections.abc import Iterator +import fcntl +import json +import os +from pathlib import Path import subprocess import threading -from unittest.mock import patch +import time +from unittest.mock import MagicMock, patch import packaging.tags import py import pytest from homeassistant import core, runner +from homeassistant.const import __version__ from homeassistant.core import HomeAssistant from homeassistant.util import executor, thread @@ -187,3 +193,244 @@ def test_enable_posix_spawn() -> None: ): runner._enable_posix_spawn() assert subprocess._USE_POSIX_SPAWN is False + + +def test_ensure_single_execution_success(tmp_path: Path) -> None: + """Test successful single instance execution.""" + config_dir = str(tmp_path) + lock_file_path = tmp_path / runner.LOCK_FILE_NAME + + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code is None + assert lock_file_path.exists() + + with open(lock_file_path, encoding="utf-8") as f: + data = json.load(f) + assert data["pid"] == os.getpid() + assert data["version"] == runner.LOCK_FILE_VERSION + assert data["ha_version"] == __version__ + assert "start_ts" in data + assert isinstance(data["start_ts"], float) + + # Lock file should still exist after context exit (we don't unlink to avoid races) + assert lock_file_path.exists() + + +def test_ensure_single_execution_blocked( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """Test that second instance is blocked when lock exists.""" + config_dir = str(tmp_path) + lock_file_path = tmp_path / runner.LOCK_FILE_NAME + + # Create and lock the file to simulate another instance + with open(lock_file_path, "w+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + instance_info = { + "pid": 12345, + "version": 1, + "ha_version": "2025.1.0", + "start_ts": time.time() - 3600, # Started 1 hour ago + } + json.dump(instance_info, lock_file) + lock_file.flush() + + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code == 1 + + captured = capfd.readouterr() + assert "Another Home Assistant instance is already running!" in captured.err + assert "PID: 12345" in captured.err + assert "Version: 2025.1.0" in captured.err + assert "Started: " in captured.err + # Should show local time since naive datetime + assert "(local time)" in captured.err + assert f"Config directory: {config_dir}" in captured.err + + +def test_ensure_single_execution_corrupt_lock_file( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """Test handling of corrupted lock file.""" + config_dir = str(tmp_path) + lock_file_path = tmp_path / runner.LOCK_FILE_NAME + + with open(lock_file_path, "w+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + lock_file.write("not valid json{]") + lock_file.flush() + + # Try to acquire lock (should set exit_code but handle corrupt file gracefully) + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code == 1 + + # Check error output + captured = capfd.readouterr() + assert "Another Home Assistant instance is already running!" in captured.err + assert "Unable to read lock file details:" in captured.err + assert f"Config directory: {config_dir}" in captured.err + + +def test_ensure_single_execution_empty_lock_file( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """Test handling of empty lock file.""" + config_dir = str(tmp_path) + lock_file_path = tmp_path / runner.LOCK_FILE_NAME + + with open(lock_file_path, "w+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + # Don't write anything - leave it empty + lock_file.flush() + + # Try to acquire lock (should set exit_code but handle empty file gracefully) + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code == 1 + + # Check error output + captured = capfd.readouterr() + assert "Another Home Assistant instance is already running!" in captured.err + assert "Unable to read lock file details." in captured.err + + +def test_ensure_single_execution_with_timezone( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """Test handling of lock file with timezone info (edge case).""" + config_dir = str(tmp_path) + lock_file_path = tmp_path / runner.LOCK_FILE_NAME + + # Note: This tests an edge case - our code doesn't create timezone-aware timestamps, + # but we handle them if they exist + with open(lock_file_path, "w+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + # Started 2 hours ago + instance_info = { + "pid": 54321, + "version": 1, + "ha_version": "2025.2.0", + "start_ts": time.time() - 7200, + } + json.dump(instance_info, lock_file) + lock_file.flush() + + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code == 1 + + captured = capfd.readouterr() + assert "Another Home Assistant instance is already running!" in captured.err + assert "PID: 54321" in captured.err + assert "Version: 2025.2.0" in captured.err + assert "Started: " in captured.err + # Should show local time indicator since fromtimestamp creates naive datetime + assert "(local time)" in captured.err + + +def test_ensure_single_execution_with_tz_abbreviation( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """Test handling of lock file when timezone abbreviation is available.""" + config_dir = str(tmp_path) + lock_file_path = tmp_path / runner.LOCK_FILE_NAME + + with open(lock_file_path, "w+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + instance_info = { + "pid": 98765, + "version": 1, + "ha_version": "2025.3.0", + "start_ts": time.time() - 1800, # Started 30 minutes ago + } + json.dump(instance_info, lock_file) + lock_file.flush() + + # Mock datetime to return a timezone abbreviation + # We use mocking because strftime("%Z") behavior is OS-specific: + # On some systems it returns empty string for naive datetimes + mock_dt = MagicMock() + + def _mock_strftime(fmt: str) -> str: + if fmt == "%Z": + return "PST" + if fmt == "%Y-%m-%d %H:%M:%S": + return "2025-09-03 10:30:45" + return "2025-09-03 10:30:45 PST" + + mock_dt.strftime.side_effect = _mock_strftime + + with patch("homeassistant.runner.datetime") as mock_datetime: + mock_datetime.fromtimestamp.return_value = mock_dt + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code == 1 + + captured = capfd.readouterr() + assert "Another Home Assistant instance is already running!" in captured.err + assert "PID: 98765" in captured.err + assert "Version: 2025.3.0" in captured.err + assert "Started: 2025-09-03 10:30:45 PST" in captured.err + # Should NOT have "(local time)" when timezone abbreviation is present + assert "(local time)" not in captured.err + + +def test_ensure_single_execution_file_not_unlinked(tmp_path: Path) -> None: + """Test that lock file is never unlinked to avoid race conditions.""" + config_dir = str(tmp_path) + lock_file_path = tmp_path / runner.LOCK_FILE_NAME + + # First run creates the lock file + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code is None + assert lock_file_path.exists() + # Get inode to verify it's the same file + stat1 = lock_file_path.stat() + + # After context exit, file should still exist + assert lock_file_path.exists() + stat2 = lock_file_path.stat() + # Verify it's the exact same file (same inode) + assert stat1.st_ino == stat2.st_ino + + # Second run should reuse the same file + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code is None + assert lock_file_path.exists() + stat3 = lock_file_path.stat() + # Still the same file (not recreated) + assert stat1.st_ino == stat3.st_ino + + # After second run, still the same file + assert lock_file_path.exists() + stat4 = lock_file_path.stat() + assert stat1.st_ino == stat4.st_ino + + +def test_ensure_single_execution_sequential_runs(tmp_path: Path) -> None: + """Test that sequential runs work correctly after lock is released.""" + config_dir = str(tmp_path) + lock_file_path = tmp_path / runner.LOCK_FILE_NAME + + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code is None + assert lock_file_path.exists() + with open(lock_file_path, encoding="utf-8") as f: + first_data = json.load(f) + + # Lock file should still exist after first run (not unlinked) + assert lock_file_path.exists() + + # Small delay to ensure different timestamp + time.sleep(0.00001) + + with runner.ensure_single_execution(config_dir) as lock: + assert lock.exit_code is None + assert lock_file_path.exists() + with open(lock_file_path, encoding="utf-8") as f: + second_data = json.load(f) + assert second_data["pid"] == os.getpid() + assert second_data["start_ts"] > first_data["start_ts"] + + # Lock file should still exist after second run (not unlinked) + assert lock_file_path.exists() diff --git a/tests/testing_config/blueprints/automation/test_event_service.yaml b/tests/testing_config/blueprints/automation/test_event_service.yaml index ec11f24fc63..4fc1216c4aa 100644 --- a/tests/testing_config/blueprints/automation/test_event_service.yaml +++ b/tests/testing_config/blueprints/automation/test_event_service.yaml @@ -5,6 +5,8 @@ blueprint: trigger_event: selector: text: + multiline: false + multiple: false service_to_call: a_number: selector: diff --git a/tests/util/test_async_iterator.py b/tests/util/test_async_iterator.py new file mode 100644 index 00000000000..866b0c8c51c --- /dev/null +++ b/tests/util/test_async_iterator.py @@ -0,0 +1,116 @@ +"""Tests for async iterator utility functions.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.util.async_iterator import ( + Abort, + AsyncIteratorReader, + AsyncIteratorWriter, +) + + +def _read_all(reader: AsyncIteratorReader) -> bytes: + output = b"" + while chunk := reader.read(500): + output += chunk + return output + + +async def test_async_iterator_reader(hass: HomeAssistant) -> None: + """Test the async iterator reader.""" + data = b"hello world" * 1000 + + async def async_gen() -> AsyncIterator[bytes]: + for _ in range(10): + yield data + + reader = AsyncIteratorReader(hass.loop, async_gen()) + assert await hass.async_add_executor_job(_read_all, reader) == data * 10 + + +async def test_async_iterator_reader_abort_early(hass: HomeAssistant) -> None: + """Test abort the async iterator reader.""" + evt = asyncio.Event() + + async def async_gen() -> AsyncIterator[bytes]: + await evt.wait() + yield b"" + + reader = AsyncIteratorReader(hass.loop, async_gen()) + reader.abort() + fut = hass.async_add_executor_job(_read_all, reader) + with pytest.raises(Abort): + await fut + + +async def test_async_iterator_reader_abort_late(hass: HomeAssistant) -> None: + """Test abort the async iterator reader.""" + evt = asyncio.Event() + + async def async_gen() -> AsyncIterator[bytes]: + await evt.wait() + yield b"" + + reader = AsyncIteratorReader(hass.loop, async_gen()) + fut = hass.async_add_executor_job(_read_all, reader) + await asyncio.sleep(0.1) + reader.abort() + with pytest.raises(Abort): + await fut + + +def _write_all(writer: AsyncIteratorWriter, data: list[bytes]) -> bytes: + for chunk in data: + assert writer.write(chunk) == len(chunk) + assert writer.write(b"") == 0 + + +async def test_async_iterator_writer(hass: HomeAssistant) -> None: + """Test the async iterator writer.""" + chunk = b"hello world" * 1000 + chunks = [chunk] * 10 + writer = AsyncIteratorWriter(hass.loop) + + fut = hass.async_add_executor_job(_write_all, writer, chunks) + + read = b"" + async for data in writer: + read += data + + await fut + + assert read == chunk * 10 + assert writer.tell() == len(read) + + +async def test_async_iterator_writer_abort_early(hass: HomeAssistant) -> None: + """Test the async iterator writer.""" + chunk = b"hello world" * 1000 + chunks = [chunk] * 10 + writer = AsyncIteratorWriter(hass.loop) + writer.abort() + + fut = hass.async_add_executor_job(_write_all, writer, chunks) + + with pytest.raises(Abort): + await fut + + +async def test_async_iterator_writer_abort_late(hass: HomeAssistant) -> None: + """Test the async iterator writer.""" + chunk = b"hello world" * 1000 + chunks = [chunk] * 10 + writer = AsyncIteratorWriter(hass.loop) + + fut = hass.async_add_executor_job(_write_all, writer, chunks) + await asyncio.sleep(0.1) + writer.abort() + + with pytest.raises(Abort): + await fut diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index d6f9d282174..d9377779b68 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -191,6 +191,24 @@ _CONVERTED_VALUE: dict[ 0.01, UnitOfApparentPower.VOLT_AMPERE, ), + ( + 10, + UnitOfApparentPower.MILLIVOLT_AMPERE, + 0.00001, + UnitOfApparentPower.KILO_VOLT_AMPERE, + ), + ( + 10, + UnitOfApparentPower.VOLT_AMPERE, + 0.01, + UnitOfApparentPower.KILO_VOLT_AMPERE, + ), + ( + 10, + UnitOfApparentPower.KILO_VOLT_AMPERE, + 10000, + UnitOfApparentPower.VOLT_AMPERE, + ), ], AreaConverter: [ # Square Meters to other units @@ -646,6 +664,7 @@ _CONVERTED_VALUE: dict[ (10, UnitOfPower.TERA_WATT, 10e12, UnitOfPower.WATT), (10, UnitOfPower.WATT, 0.01, UnitOfPower.KILO_WATT), (10, UnitOfPower.MILLIWATT, 0.01, UnitOfPower.WATT), + (10, UnitOfPower.BTU_PER_HOUR, 2.9307107, UnitOfPower.WATT), ], PressureConverter: [ (1000, UnitOfPressure.HPA, 14.5037743897, UnitOfPressure.PSI), @@ -654,12 +673,21 @@ _CONVERTED_VALUE: dict[ (1000, UnitOfPressure.HPA, 100, UnitOfPressure.KPA), (1000, UnitOfPressure.HPA, 1000, UnitOfPressure.MBAR), (1000, UnitOfPressure.HPA, 100, UnitOfPressure.CBAR), + (1000, UnitOfPressure.HPA, 401.46307866177, UnitOfPressure.INH2O), (100, UnitOfPressure.KPA, 14.5037743897, UnitOfPressure.PSI), (100, UnitOfPressure.KPA, 29.5299801647, UnitOfPressure.INHG), (100, UnitOfPressure.KPA, 100000, UnitOfPressure.PA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.HPA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.MBAR), (100, UnitOfPressure.KPA, 100, UnitOfPressure.CBAR), + (100, UnitOfPressure.INH2O, 3.6127291827353996, UnitOfPressure.PSI), + (100, UnitOfPressure.INH2O, 186.83201548767, UnitOfPressure.MMHG), + (100, UnitOfPressure.INH2O, 7.3555912463681, UnitOfPressure.INHG), + (100, UnitOfPressure.INH2O, 24908.890833333, UnitOfPressure.PA), + (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.HPA), + (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.MBAR), + (100, UnitOfPressure.INH2O, 24.908890833333, UnitOfPressure.KPA), + (100, UnitOfPressure.INH2O, 24.908890833333, UnitOfPressure.CBAR), (30, UnitOfPressure.INHG, 14.7346266155, UnitOfPressure.PSI), (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.KPA), (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.HPA), @@ -667,6 +695,7 @@ _CONVERTED_VALUE: dict[ (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.MBAR), (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.CBAR), (30, UnitOfPressure.INHG, 762, UnitOfPressure.MMHG), + (30, UnitOfPressure.INHG, 407.85300589959, UnitOfPressure.INH2O), (30, UnitOfPressure.MMHG, 0.580103, UnitOfPressure.PSI), (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.KPA), (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.HPA), @@ -674,6 +703,7 @@ _CONVERTED_VALUE: dict[ (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.MBAR), (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.CBAR), (30, UnitOfPressure.MMHG, 1.181102, UnitOfPressure.INHG), + (30, UnitOfPressure.MMHG, 16.0572051431838, UnitOfPressure.INH2O), (5, UnitOfPressure.BAR, 72.51887, UnitOfPressure.PSI), ], ReactiveEnergyConverter: [ @@ -721,6 +751,20 @@ _CONVERTED_VALUE: dict[ (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.106856, UnitOfSpeed.MILES_PER_HOUR), # 5 mi/h * 1.609 km/mi = 8.04672 km/h (5, UnitOfSpeed.MILES_PER_HOUR, 8.04672, UnitOfSpeed.KILOMETERS_PER_HOUR), + # 300 m/min / 60 s/min = 5 m/s + ( + 300, + UnitOfSpeed.METERS_PER_MINUTE, + 5, + UnitOfSpeed.METERS_PER_SECOND, + ), + # 5 m/s * 60 s/min = 300 m/min + ( + 5, + UnitOfSpeed.METERS_PER_SECOND, + 300, + UnitOfSpeed.METERS_PER_MINUTE, + ), # 5 in/day * 25.4 mm/in = 127 mm/day ( 5, @@ -861,6 +905,11 @@ _CONVERTED_VALUE: dict[ (5, UnitOfVolume.CENTUM_CUBIC_FEET, 478753.24, UnitOfVolume.FLUID_OUNCES), (5, UnitOfVolume.CENTUM_CUBIC_FEET, 3740.26, UnitOfVolume.GALLONS), (5, UnitOfVolume.CENTUM_CUBIC_FEET, 14158.42, UnitOfVolume.LITERS), + (5, UnitOfVolume.MILLE_CUBIC_FEET, 5000, UnitOfVolume.CUBIC_FEET), + (5, UnitOfVolume.MILLE_CUBIC_FEET, 141.5842, UnitOfVolume.CUBIC_METERS), + (5, UnitOfVolume.MILLE_CUBIC_FEET, 4787532.4, UnitOfVolume.FLUID_OUNCES), + (5, UnitOfVolume.MILLE_CUBIC_FEET, 37402.6, UnitOfVolume.GALLONS), + (5, UnitOfVolume.MILLE_CUBIC_FEET, 141584.2, UnitOfVolume.LITERS), ], VolumeFlowRateConverter: [ ( diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 87a9729700e..54e9d4080e3 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -433,6 +433,11 @@ def test_get_unit_system_invalid(key: str) -> None: UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_METERS, ), + ( + SensorDeviceClass.GAS, + UnitOfVolume.MILLE_CUBIC_FEET, + UnitOfVolume.CUBIC_METERS, + ), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), (SensorDeviceClass.GAS, UnitOfVolume.LITERS, None), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, None), @@ -510,6 +515,11 @@ def test_get_unit_system_invalid(key: str) -> None: UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_METERS, ), + ( + SensorDeviceClass.VOLUME, + UnitOfVolume.MILLE_CUBIC_FEET, + UnitOfVolume.CUBIC_METERS, + ), (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), (SensorDeviceClass.VOLUME, UnitOfVolume.FLUID_OUNCES, UnitOfVolume.MILLILITERS), (SensorDeviceClass.VOLUME, UnitOfVolume.GALLONS, UnitOfVolume.LITERS), @@ -523,6 +533,11 @@ def test_get_unit_system_invalid(key: str) -> None: UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_METERS, ), + ( + SensorDeviceClass.WATER, + UnitOfVolume.MILLE_CUBIC_FEET, + UnitOfVolume.CUBIC_METERS, + ), (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, UnitOfVolume.LITERS), (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, None), @@ -599,6 +614,7 @@ UNCONVERTED_UNITS_METRIC_SYSTEM = { UnitOfSpeed.BEAUFORT, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS, + UnitOfSpeed.METERS_PER_MINUTE, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.MILLIMETERS_PER_SECOND, UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, @@ -690,6 +706,7 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: (SensorDeviceClass.DISTANCE, "very_long", None), # Test gas meter conversion (SensorDeviceClass.GAS, UnitOfVolume.CENTUM_CUBIC_FEET, None), + (SensorDeviceClass.GAS, UnitOfVolume.MILLE_CUBIC_FEET, None), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), (SensorDeviceClass.GAS, UnitOfVolume.LITERS, UnitOfVolume.CUBIC_FEET), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, None), @@ -770,6 +787,7 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, UnitOfVolume.GALLONS), (SensorDeviceClass.VOLUME, UnitOfVolume.MILLILITERS, UnitOfVolume.FLUID_OUNCES), (SensorDeviceClass.VOLUME, UnitOfVolume.CENTUM_CUBIC_FEET, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.MILLE_CUBIC_FEET, None), (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_FEET, None), (SensorDeviceClass.VOLUME, UnitOfVolume.FLUID_OUNCES, None), (SensorDeviceClass.VOLUME, UnitOfVolume.GALLONS, None), @@ -778,6 +796,7 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), (SensorDeviceClass.WATER, UnitOfVolume.LITERS, UnitOfVolume.GALLONS), (SensorDeviceClass.WATER, UnitOfVolume.CENTUM_CUBIC_FEET, None), + (SensorDeviceClass.WATER, UnitOfVolume.MILLE_CUBIC_FEET, None), (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, None), (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, None), (SensorDeviceClass.WATER, "very_much", None), @@ -828,7 +847,11 @@ UNCONVERTED_UNITS_US_SYSTEM = { UnitOfLength.MILES, UnitOfLength.YARDS, ), - SensorDeviceClass.GAS: (UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET), + SensorDeviceClass.GAS: ( + UnitOfVolume.CENTUM_CUBIC_FEET, + UnitOfVolume.MILLE_CUBIC_FEET, + UnitOfVolume.CUBIC_FEET, + ), SensorDeviceClass.PRECIPITATION: (UnitOfLength.INCHES,), SensorDeviceClass.PRECIPITATION_INTENSITY: ( UnitOfVolumetricFlux.INCHES_PER_DAY, @@ -846,12 +869,14 @@ UNCONVERTED_UNITS_US_SYSTEM = { ), SensorDeviceClass.VOLUME: ( UnitOfVolume.CENTUM_CUBIC_FEET, + UnitOfVolume.MILLE_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.FLUID_OUNCES, UnitOfVolume.GALLONS, ), SensorDeviceClass.WATER: ( UnitOfVolume.CENTUM_CUBIC_FEET, + UnitOfVolume.MILLE_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.GALLONS, ),